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

10,373 lines 421.5 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 that support it
6656 // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
6657 if ($is_gpt5_model && $selected_model !== 'gpt-5.2' && $selected_model !== 'gpt-5.1-chat-latest') {
6658 // GPT-5.1 uses 'low' instead of 'minimal'
6659 if ($selected_model === 'gpt-5.1-2025-11-13') {
6660 $request_body['reasoning_effort'] = 'low';
6661 } else {
6662 $request_body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
6663 }
6664 }
6665
6666 $body = json_encode($request_body);
6667
6668 // Setup streaming headers now that we know we're actually streaming
6669 $this->setup_streaming_headers();
6670
6671 // Use cURL for streaming support
6672 $ch = curl_init();
6673 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
6674 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6675 curl_setopt($ch, CURLOPT_POST, true);
6676 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6677 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6678 'Content-Type: application/json',
6679 'Authorization: Bearer ' . $api_key
6680 ));
6681 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6682 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6683
6684 $full_response = ''; // Accumulate full response for saving
6685 $stream_started = false;
6686 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
6687
6688 // Buffer control for real-time streaming
6689 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6690 // Send testing data as the first event if available
6691 if (!$stream_started && $testing_data !== null) {
6692 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6693 flush();
6694 $stream_started = true;
6695 }
6696
6697 // CRITICAL FIX: Append new data to buffer
6698 $buffer .= $data;
6699
6700 // Process complete lines only
6701 $lines = explode("\n", $buffer);
6702
6703 // CRITICAL FIX: Keep the last incomplete line in the buffer
6704 // The last element might be incomplete, so keep it in buffer
6705 $buffer = array_pop($lines);
6706
6707 foreach ($lines as $line) {
6708 // Skip empty lines
6709 if (trim($line) === '') {
6710 continue;
6711 }
6712
6713 // Only process lines that start with "data: "
6714 if (strpos($line, 'data: ') !== 0) {
6715 continue;
6716 }
6717
6718 $json_str = substr($line, 6); // Remove 'data: ' prefix
6719
6720 if (trim($json_str) === '[DONE]') {
6721 echo "data: [DONE]\n\n";
6722 flush();
6723 continue;
6724 }
6725
6726 // Try to decode JSON
6727 $json = json_decode(trim($json_str), true);
6728 if ($json && isset($json['choices'][0]['delta']['content'])) {
6729 $content = $json['choices'][0]['delta']['content'];
6730 $full_response .= $content; // Accumulate the full response
6731
6732 // Send as SSE format
6733 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6734 flush();
6735 }
6736 }
6737
6738 return strlen($data);
6739 });
6740
6741 $response = curl_exec($ch);
6742 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6743
6744 if (curl_errno($ch) || $http_code !== 200) {
6745 $curl_error = curl_error($ch);
6746 curl_close($ch);
6747
6748 // Fallback to regular response
6749 $regular_response = $this->mxchat_generate_response_openai(
6750 $selected_model,
6751 $api_key,
6752 $conversation_history,
6753 $relevant_content
6754 );
6755
6756 // FIXED: Check if regular response returned an error
6757 if (is_array($regular_response) && isset($regular_response['error'])) {
6758 // Send error in SSE format since we're in streaming mode
6759 echo "data: " . json_encode([
6760 'error' => true,
6761 'error_message' => $regular_response['error'],
6762 'error_code' => $regular_response['error_code'] ?? 'api_error',
6763 'text' => $regular_response['error'],
6764 'message' => $regular_response['error']
6765 ]) . "\n\n";
6766 echo "data: [DONE]\n\n";
6767 flush();
6768 return true;
6769 }
6770
6771 $response_data = [
6772 'text' => $regular_response,
6773 'html' => '',
6774 'session_id' => $session_id
6775 ];
6776
6777 if ($testing_data !== null) {
6778 $response_data['testing_data'] = $testing_data;
6779 }
6780
6781 header('Content-Type: application/json');
6782 echo json_encode($response_data);
6783 return true;
6784 }
6785
6786 curl_close($ch);
6787
6788 // Save the complete response to maintain chat persistence
6789 if (!empty($full_response) && !empty($session_id)) {
6790 // Prepare RAG context for streaming response
6791 $rag_context_for_storage = null;
6792 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
6793 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
6794
6795 if ($has_rag_data || $has_action_data) {
6796 $rag_context_for_storage = [];
6797
6798 if ($has_rag_data) {
6799 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
6800 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
6801 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
6802 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
6803 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
6804 }
6805
6806 if ($has_action_data) {
6807 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
6808 }
6809 }
6810 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6811 }
6812
6813 return true; // Indicate streaming completed successfully
6814
6815 } catch (Exception $e) {
6816 // Fallback to regular response
6817 $regular_response = $this->mxchat_generate_response_openai(
6818 $selected_model,
6819 $api_key,
6820 $conversation_history,
6821 $relevant_content
6822 );
6823
6824 // FIXED: Check if regular response returned an error
6825 if (is_array($regular_response) && isset($regular_response['error'])) {
6826 // Send error in SSE format since we're in streaming mode
6827 echo "data: " . json_encode([
6828 'error' => true,
6829 'error_message' => $regular_response['error'],
6830 'error_code' => $regular_response['error_code'] ?? 'api_error',
6831 'text' => $regular_response['error'],
6832 'message' => $regular_response['error']
6833 ]) . "\n\n";
6834 echo "data: [DONE]\n\n";
6835 flush();
6836 return true;
6837 }
6838
6839 $response_data = [
6840 'text' => $regular_response,
6841 'html' => '',
6842 'session_id' => $session_id
6843 ];
6844
6845 if ($testing_data !== null) {
6846 $response_data['testing_data'] = $testing_data;
6847 }
6848
6849 header('Content-Type: application/json');
6850 echo json_encode($response_data);
6851 return true;
6852 }
6853 }
6854
6855 /**
6856 * Generate response using OpenAI Responses API with web search tool
6857 * This uses the newer Responses API which supports web search functionality
6858 */
6859 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
6860 try {
6861 $bot_id = $this->get_current_bot_id($session_id);
6862 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6863
6864 if (!is_array($conversation_history)) {
6865 $conversation_history = array();
6866 }
6867
6868 // Build the input for Responses API
6869 // The Responses API uses a different format - we need to construct the input properly
6870 $input_parts = [];
6871
6872 // Add system instructions as context
6873 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
6874
6875 // Build conversation as input items for Responses API
6876 foreach ($conversation_history as $message) {
6877 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6878 $role = $message['role'];
6879 if ($role === 'bot' || $role === 'agent') {
6880 $role = 'assistant';
6881 }
6882 if (!in_array($role, ['assistant', 'user'])) {
6883 $role = 'user';
6884 }
6885 $input_parts[] = [
6886 'type' => 'message',
6887 'role' => $role,
6888 'content' => $message['content']
6889 ];
6890 }
6891 }
6892
6893 // Build request body for Responses API with web search
6894 $request_body = [
6895 'model' => $selected_model,
6896 'input' => $input_parts,
6897 'instructions' => $system_context,
6898 'tools' => [
6899 ['type' => 'web_search']
6900 ],
6901 'stream' => $streaming
6902 ];
6903
6904 // Add reasoning effort for supported models (not for gpt-5 with minimal which doesn't support web search)
6905 // Per OpenAI docs: web search is not supported with gpt-5 minimal reasoning
6906 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
6907 if ($is_gpt5_model && $selected_model !== 'gpt-5.2') {
6908 // Use 'low' for GPT-5.1, skip for others to avoid 'minimal' which doesn't support web search
6909 if ($selected_model === 'gpt-5.1-2025-11-13') {
6910 $request_body['reasoning'] = ['effort' => 'low'];
6911 }
6912 // For other GPT-5 models, don't set reasoning to allow web search
6913 }
6914
6915 error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
6916
6917 if ($streaming) {
6918 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
6919 } else {
6920 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
6921 }
6922
6923 } catch (Exception $e) {
6924 error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
6925 return [
6926 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
6927 'error_code' => 'web_search_exception'
6928 ];
6929 }
6930 }
6931
6932 /**
6933 * Handle non-streaming web search response
6934 */
6935 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
6936 $request_body['stream'] = false;
6937
6938 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
6939 'headers' => array(
6940 'Authorization' => 'Bearer ' . $api_key,
6941 'Content-Type' => 'application/json'
6942 ),
6943 'body' => json_encode($request_body),
6944 'timeout' => 90 // Web search can take longer
6945 ));
6946
6947 if (is_wp_error($response)) {
6948 error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
6949 return [
6950 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
6951 'error_code' => 'web_search_connection_error'
6952 ];
6953 }
6954
6955 $response_code = wp_remote_retrieve_response_code($response);
6956 $response_body = wp_remote_retrieve_body($response);
6957
6958 error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
6959 error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
6960
6961 if ($response_code !== 200) {
6962 $error_data = json_decode($response_body, true);
6963 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
6964 return [
6965 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
6966 'error_code' => 'web_search_api_error'
6967 ];
6968 }
6969
6970 $result = json_decode($response_body, true);
6971
6972 if (json_last_error() !== JSON_ERROR_NONE) {
6973 return [
6974 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
6975 'error_code' => 'web_search_json_error'
6976 ];
6977 }
6978
6979 // Extract the response text and citations from Responses API format
6980 $output_text = '';
6981 $citations = [];
6982
6983 if (isset($result['output'])) {
6984 foreach ($result['output'] as $output_item) {
6985 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
6986 foreach ($output_item['content'] as $content_item) {
6987 if ($content_item['type'] === 'output_text') {
6988 $output_text .= $content_item['text'];
6989
6990 // Extract citations/annotations
6991 if (isset($content_item['annotations'])) {
6992 foreach ($content_item['annotations'] as $annotation) {
6993 if ($annotation['type'] === 'url_citation') {
6994 $citations[] = [
6995 'url' => $annotation['url'],
6996 'title' => $annotation['title'] ?? ''
6997 ];
6998 }
6999 }
7000 }
7001 }
7002 }
7003 }
7004 }
7005 }
7006
7007 // If we have citations, append them to the response
7008 if (!empty($citations)) {
7009 $output_text .= "\n\n**Sources:**\n";
7010 $seen_urls = [];
7011 foreach ($citations as $citation) {
7012 if (!in_array($citation['url'], $seen_urls)) {
7013 $seen_urls[] = $citation['url'];
7014 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7015 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7016 }
7017 }
7018 }
7019
7020 // Save to transcript
7021 if (!empty($output_text) && !empty($session_id)) {
7022 $this->mxchat_save_chat_message($session_id, 'bot', $output_text);
7023 }
7024
7025 return $output_text;
7026 }
7027
7028 /**
7029 * Handle streaming web search response using Responses API
7030 */
7031 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7032 $request_body['stream'] = true;
7033
7034 // Check if we can stream
7035 if (headers_sent() || !function_exists('curl_init')) {
7036 // Fallback to non-streaming
7037 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7038 }
7039
7040 // Setup streaming headers
7041 $this->setup_streaming_headers();
7042
7043 $ch = curl_init();
7044 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
7045 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7046 curl_setopt($ch, CURLOPT_POST, true);
7047 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
7048 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7049 'Content-Type: application/json',
7050 'Authorization: Bearer ' . $api_key
7051 ));
7052 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7053 curl_setopt($ch, CURLOPT_TIMEOUT, 120); // Web search can take longer
7054
7055 $full_response = '';
7056 $stream_started = false;
7057 $buffer = '';
7058 $citations = [];
7059
7060 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
7061 // Send testing data as first event if available
7062 if (!$stream_started && $testing_data !== null) {
7063 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7064 flush();
7065 $stream_started = true;
7066 }
7067
7068 $buffer .= $data;
7069 $lines = explode("\n", $buffer);
7070 $buffer = array_pop($lines);
7071
7072 foreach ($lines as $line) {
7073 if (trim($line) === '') continue;
7074 if (strpos($line, 'data: ') !== 0) continue;
7075
7076 $json_str = substr($line, 6);
7077
7078 if (trim($json_str) === '[DONE]') {
7079 // Append citations if we have any
7080 if (!empty($citations)) {
7081 $citation_text = "\n\n**Sources:**\n";
7082 $seen_urls = [];
7083 foreach ($citations as $citation) {
7084 if (!in_array($citation['url'], $seen_urls)) {
7085 $seen_urls[] = $citation['url'];
7086 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7087 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7088 }
7089 }
7090 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
7091 $full_response .= $citation_text;
7092 flush();
7093 }
7094 echo "data: [DONE]\n\n";
7095 flush();
7096 continue;
7097 }
7098
7099 $json = json_decode(trim($json_str), true);
7100 if (!$json) continue;
7101
7102 // Handle Responses API streaming events
7103 // The format is different from Chat Completions
7104 if (isset($json['type'])) {
7105 switch ($json['type']) {
7106 case 'response.output_text.delta':
7107 // Text content delta
7108 if (isset($json['delta'])) {
7109 $content = $json['delta'];
7110 $full_response .= $content;
7111 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7112 flush();
7113 }
7114 break;
7115
7116 case 'response.output_item.done':
7117 // Check for citations in completed items
7118 if (isset($json['item']['content'])) {
7119 foreach ($json['item']['content'] as $content_item) {
7120 if (isset($content_item['annotations'])) {
7121 foreach ($content_item['annotations'] as $annotation) {
7122 if ($annotation['type'] === 'url_citation') {
7123 $citations[] = [
7124 'url' => $annotation['url'],
7125 'title' => $annotation['title'] ?? ''
7126 ];
7127 }
7128 }
7129 }
7130 }
7131 }
7132 break;
7133 }
7134 }
7135 }
7136
7137 return strlen($data);
7138 });
7139
7140 $response = curl_exec($ch);
7141 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7142
7143 if (curl_errno($ch) || $http_code !== 200) {
7144 $curl_error = curl_error($ch);
7145 curl_close($ch);
7146
7147 error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
7148
7149 // Fallback to non-streaming
7150 $fallback_response = $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7151
7152 if (is_array($fallback_response) && isset($fallback_response['error'])) {
7153 echo "data: " . json_encode([
7154 'error' => true,
7155 'error_message' => $fallback_response['error'],
7156 'error_code' => $fallback_response['error_code'] ?? 'web_search_error'
7157 ]) . "\n\n";
7158 echo "data: [DONE]\n\n";
7159 flush();
7160 return true;
7161 }
7162
7163 $response_data = [
7164 'text' => $fallback_response,
7165 'html' => '',
7166 'session_id' => $session_id
7167 ];
7168 if ($testing_data !== null) {
7169 $response_data['testing_data'] = $testing_data;
7170 }
7171 header('Content-Type: application/json');
7172 echo json_encode($response_data);
7173 return true;
7174 }
7175
7176 curl_close($ch);
7177
7178 // Save the complete response
7179 if (!empty($full_response) && !empty($session_id)) {
7180 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
7181 }
7182
7183 return true;
7184 }
7185
7186 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7187 try {
7188 // Get bot ID from session or request
7189 $bot_id = $this->get_current_bot_id($session_id);
7190
7191 // Get system prompt instructions using centralized function
7192 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7193 // Ensure conversation_history is an array
7194 if (!is_array($conversation_history)) {
7195 $conversation_history = array();
7196 }
7197
7198 // Clean and validate conversation history
7199 foreach ($conversation_history as &$message) {
7200 // Convert bot and agent roles to assistant
7201 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7202 $message['role'] = 'assistant';
7203 }
7204
7205 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7206 if (!in_array($message['role'], ['assistant', 'user'])) {
7207 $message['role'] = 'user';
7208 }
7209
7210 // Ensure content field exists
7211 if (!isset($message['content']) || empty($message['content'])) {
7212 $message['content'] = '';
7213 }
7214
7215 // Remove any unsupported fields
7216 $message = array_intersect_key($message, array_flip(['role', 'content']));
7217 }
7218
7219 // Add relevant content as the latest user message
7220 $conversation_history[] = [
7221 'role' => 'user',
7222 'content' => $relevant_content
7223 ];
7224
7225 // Prepare the request body with stream: true
7226 $body = json_encode([
7227 'model' => $selected_model,
7228 'messages' => $conversation_history,
7229 'max_tokens' => 1000,
7230 'temperature' => 0.8,
7231 'system' => $system_prompt_instructions,
7232 'stream' => true
7233 ]);
7234
7235 // Check if we can actually stream (headers not sent, etc.)
7236 if (headers_sent() || !function_exists('curl_init')) {
7237 // Fallback to regular response with testing data
7238 //error_log("MxChat: Streaming not possible, falling back to regular response");
7239 $regular_response = $this->mxchat_generate_response_claude(
7240 $selected_model,
7241 $claude_api_key,
7242 array_slice($conversation_history, 0, -1), // Remove the added content
7243 $relevant_content
7244 );
7245
7246 // Save bot response to transcript
7247 if (!empty($regular_response) && !empty($session_id)) {
7248 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7249 }
7250
7251 // Return as JSON with testing data
7252 $response_data = [
7253 'text' => $regular_response,
7254 'html' => '',
7255 'session_id' => $session_id
7256 ];
7257
7258 if ($testing_data !== null) {
7259 $response_data['testing_data'] = $testing_data;
7260 //error_log("MxChat Testing: Added testing data to Claude fallback response");
7261 }
7262
7263 // Clear any streaming headers and send JSON
7264 if (headers_sent() === false) {
7265 header('Content-Type: application/json');
7266 }
7267 echo json_encode($response_data);
7268 return true; // Indicate we handled the response
7269 }
7270
7271 // Setup streaming headers now that we know we're actually streaming
7272 $this->setup_streaming_headers();
7273
7274 // Use cURL for streaming support
7275 $ch = curl_init();
7276 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
7277 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7278 curl_setopt($ch, CURLOPT_POST, true);
7279 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7280 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7281 'Content-Type: application/json',
7282 'x-api-key: ' . $claude_api_key,
7283 'anthropic-version: 2023-06-01'
7284 ));
7285 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7286 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7287
7288 $full_response = ''; // Accumulate full response for saving
7289 $stream_started = false;
7290 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7291
7292 // Buffer control for real-time streaming
7293 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7294 // Send testing data as the first event if available
7295 if (!$stream_started && $testing_data !== null) {
7296 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7297 flush();
7298 $stream_started = true;
7299 //error_log("MxChat Testing: Sent testing data in Claude stream");
7300 }
7301
7302 // CRITICAL FIX: Append new data to buffer
7303 $buffer .= $data;
7304
7305 // Process complete lines only
7306 $lines = explode("\n", $buffer);
7307
7308 // CRITICAL FIX: Keep the last incomplete line in the buffer
7309 // The last element might be incomplete, so keep it in buffer
7310 $buffer = array_pop($lines);
7311
7312 foreach ($lines as $line) {
7313 if (trim($line) === '') {
7314 continue;
7315 }
7316
7317 // Claude uses event: and data: format
7318 if (strpos($line, 'event: ') === 0) {
7319 // Store the event type for the next data line
7320 continue;
7321 }
7322
7323 if (strpos($line, 'data: ') === 0) {
7324 $json_str = substr($line, 6); // Remove 'data: ' prefix
7325
7326 $json = json_decode(trim($json_str), true);
7327 if (json_last_error() !== JSON_ERROR_NONE) {
7328 continue;
7329 }
7330
7331 // Handle different event types
7332 if (isset($json['type'])) {
7333 switch ($json['type']) {
7334 case 'content_block_delta':
7335 if (isset($json['delta']['text'])) {
7336 $content = $json['delta']['text'];
7337 $full_response .= $content; // Accumulate
7338 // Send as SSE format compatible with your frontend
7339 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7340 flush();
7341 }
7342 break;
7343
7344 case 'message_stop':
7345 echo "data: [DONE]\n\n";
7346 flush();
7347 break;
7348
7349 case 'error':
7350 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
7351 flush();
7352 break;
7353 }
7354 }
7355 }
7356 }
7357
7358 return strlen($data);
7359 });
7360
7361 $response = curl_exec($ch);
7362 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7363
7364 if (curl_errno($ch)) {
7365 curl_close($ch);
7366 throw new Exception('cURL Error: ' . curl_error($ch));
7367 }
7368
7369 curl_close($ch);
7370
7371 if ($http_code !== 200) {
7372 // Fallback to regular response
7373 //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
7374 $regular_response = $this->mxchat_generate_response_claude(
7375 $selected_model,
7376 $claude_api_key,
7377 array_slice($conversation_history, 0, -1), // Remove the added content
7378 $relevant_content
7379 );
7380
7381 // FIXED: Check if regular response returned an error
7382 if (is_array($regular_response) && isset($regular_response['error'])) {
7383 // Send error in SSE format since we're in streaming mode
7384 echo "data: " . json_encode([
7385 'error' => true,
7386 'error_message' => $regular_response['error'],
7387 'error_code' => $regular_response['error_code'] ?? 'api_error',
7388 'text' => $regular_response['error'],
7389 'message' => $regular_response['error']
7390 ]) . "\n\n";
7391 echo "data: [DONE]\n\n";
7392 flush();
7393 return true;
7394 }
7395
7396 $response_data = [
7397 'text' => $regular_response,
7398 'html' => '',
7399 'session_id' => $session_id
7400 ];
7401
7402 if ($testing_data !== null) {
7403 $response_data['testing_data'] = $testing_data;
7404 //error_log("MxChat Testing: Added testing data to Claude error fallback");
7405 }
7406
7407 header('Content-Type: application/json');
7408 echo json_encode($response_data);
7409 return true;
7410 }
7411
7412 // Save the complete response to maintain chat persistence
7413 if (!empty($full_response) && !empty($session_id)) {
7414 // Prepare RAG context for streaming response
7415 $rag_context_for_storage = null;
7416 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7417 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7418
7419 if ($has_rag_data || $has_action_data) {
7420 $rag_context_for_storage = [];
7421
7422 if ($has_rag_data) {
7423 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7424 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7425 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7426 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7427 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7428 }
7429
7430 if ($has_action_data) {
7431 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7432 }
7433 }
7434 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7435 }
7436
7437 return true; // Indicate streaming completed successfully
7438
7439 } catch (Exception $e) {
7440 //error_log("MxChat Claude streaming exception: " . $e->getMessage());
7441
7442 // Fallback to regular response on exception
7443 $regular_response = $this->mxchat_generate_response_claude(
7444 $selected_model,
7445 $claude_api_key,
7446 $conversation_history,
7447 $relevant_content
7448 );
7449
7450 // FIXED: Check if regular response returned an error
7451 if (is_array($regular_response) && isset($regular_response['error'])) {
7452 // Send error in SSE format since we're in streaming mode
7453 echo "data: " . json_encode([
7454 'error' => true,
7455 'error_message' => $regular_response['error'],
7456 'error_code' => $regular_response['error_code'] ?? 'api_error',
7457 'text' => $regular_response['error'],
7458 'message' => $regular_response['error']
7459 ]) . "\n\n";
7460 echo "data: [DONE]\n\n";
7461 flush();
7462 return true;
7463 }
7464
7465 $response_data = [
7466 'text' => $regular_response,
7467 'html' => '',
7468 'session_id' => $session_id
7469 ];
7470
7471 if ($testing_data !== null) {
7472 $response_data['testing_data'] = $testing_data;
7473 //error_log("MxChat Testing: Added testing data to Claude exception fallback");
7474 }
7475
7476 header('Content-Type: application/json');
7477 echo json_encode($response_data);
7478 return true;
7479 }
7480 }
7481 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7482 try {
7483 // Get bot ID from session or request
7484 $bot_id = $this->get_current_bot_id($session_id);
7485
7486 // Get system prompt instructions using centralized function
7487 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7488
7489 // Ensure conversation_history is an array
7490 if (!is_array($conversation_history)) {
7491 $conversation_history = array();
7492 }
7493
7494 // Format conversation history for X.AI (same as OpenAI format)
7495 $formatted_conversation = array();
7496
7497 $formatted_conversation[] = array(
7498 'role' => 'system',
7499 'content' => $system_prompt_instructions . " " . $relevant_content
7500 );
7501
7502 foreach ($conversation_history as $message) {
7503 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7504 $role = $message['role'];
7505 if ($role === 'bot' || $role === 'agent') {
7506 $role = 'assistant';
7507 }
7508 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7509 $role = 'user';
7510 }
7511 $formatted_conversation[] = array(
7512 'role' => $role,
7513 'content' => $message['content']
7514 );
7515 }
7516 }
7517
7518 // Check if we can actually stream
7519 if (headers_sent() || !function_exists('curl_init')) {
7520 // Fallback to regular response with testing data
7521 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
7522 $regular_response = $this->mxchat_generate_response_xai(
7523 $selected_model,
7524 $xai_api_key,
7525 $conversation_history,
7526 $relevant_content
7527 );
7528
7529 // Save bot response to transcript
7530 if (!empty($regular_response) && !empty($session_id)) {
7531 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7532 }
7533
7534 $response_data = [
7535 'text' => $regular_response,
7536 'html' => '',
7537 'session_id' => $session_id
7538 ];
7539
7540 if ($testing_data !== null) {
7541 $response_data['testing_data'] = $testing_data;
7542 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
7543 }
7544
7545 header('Content-Type: application/json');
7546 echo json_encode($response_data);
7547 return true;
7548 }
7549
7550 // Prepare the request body with stream: true
7551 $body = json_encode([
7552 'model' => $selected_model,
7553 'messages' => $formatted_conversation,
7554 'temperature' => 0.8,
7555 'stream' => true
7556 ]);
7557
7558 // Setup streaming headers now that we know we're actually streaming
7559 $this->setup_streaming_headers();
7560
7561 // Use cURL for streaming support
7562 $ch = curl_init();
7563 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
7564 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7565 curl_setopt($ch, CURLOPT_POST, true);
7566 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7567 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7568 'Content-Type: application/json',
7569 'Authorization: Bearer ' . $xai_api_key
7570 ));
7571 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7572 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7573
7574 $full_response = ''; // Accumulate full response for saving
7575 $stream_started = false;
7576 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7577
7578 // Buffer control for real-time streaming
7579 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7580 // Send testing data as the first event if available
7581 if (!$stream_started && $testing_data !== null) {
7582 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7583 flush();
7584 $stream_started = true;
7585 //error_log("MxChat Testing: Sent testing data in X.AI stream");
7586 }
7587
7588 // CRITICAL FIX: Append new data to buffer
7589 $buffer .= $data;
7590
7591 // Process complete lines only
7592 $lines = explode("\n", $buffer);
7593
7594 // CRITICAL FIX: Keep the last incomplete line in the buffer
7595 // The last element might be incomplete, so keep it in buffer
7596 $buffer = array_pop($lines);
7597
7598 foreach ($lines as $line) {
7599 // Skip empty lines
7600 if (trim($line) === '') {
7601 continue;
7602 }
7603
7604 // Only process lines that start with "data: "
7605 if (strpos($line, 'data: ') !== 0) {
7606 continue;
7607 }
7608
7609 $json_str = substr($line, 6); // Remove 'data: ' prefix
7610
7611 if (trim($json_str) === '[DONE]') {
7612 echo "data: [DONE]\n\n";
7613 flush();
7614 continue;
7615 }
7616
7617 // Try to decode JSON
7618 $json = json_decode(trim($json_str), true);
7619 if ($json && isset($json['choices'][0]['delta']['content'])) {
7620 $content = $json['choices'][0]['delta']['content'];
7621 $full_response .= $content; // Accumulate
7622 // Send as SSE format
7623 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7624 flush();
7625 }
7626 }
7627
7628 return strlen($data);
7629 });
7630
7631 $response = curl_exec($ch);
7632 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7633
7634 if (curl_errno($ch) || $http_code !== 200) {
7635 curl_close($ch);
7636
7637 // Fallback to regular response
7638 //error_log("MxChat: X.AI streaming failed, falling back");
7639 $regular_response = $this->mxchat_generate_response_xai(
7640 $selected_model,
7641 $xai_api_key,
7642 $conversation_history,
7643 $relevant_content
7644 );
7645
7646 $response_data = [
7647 'text' => $regular_response,
7648 'html' => '',
7649 'session_id' => $session_id
7650 ];
7651
7652 if ($testing_data !== null) {
7653 $response_data['testing_data'] = $testing_data;
7654 //error_log("MxChat Testing: Added testing data to X.AI error fallback");
7655 }
7656
7657 header('Content-Type: application/json');
7658 echo json_encode($response_data);
7659 return true;
7660 }
7661
7662 curl_close($ch);
7663
7664 // Save the complete response to maintain chat persistence
7665 if (!empty($full_response) && !empty($session_id)) {
7666 // Prepare RAG context for streaming response
7667 $rag_context_for_storage = null;
7668 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7669 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7670
7671 if ($has_rag_data || $has_action_data) {
7672 $rag_context_for_storage = [];
7673
7674 if ($has_rag_data) {
7675 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7676 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7677 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7678 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7679 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7680 }
7681
7682 if ($has_action_data) {
7683 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7684 }
7685 }
7686 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7687 }
7688
7689 return true; // Indicate streaming completed successfully
7690
7691 } catch (Exception $e) {
7692 //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
7693
7694 // Fallback to regular response
7695 $regular_response = $this->mxchat_generate_response_xai(
7696 $selected_model,
7697 $xai_api_key,
7698 $conversation_history,
7699 $relevant_content
7700 );
7701
7702 $response_data = [
7703 'text' => $regular_response,
7704 'html' => '',
7705 'session_id' => $session_id
7706 ];
7707
7708 if ($testing_data !== null) {
7709 $response_data['testing_data'] = $testing_data;
7710 //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
7711 }
7712
7713 header('Content-Type: application/json');
7714 echo json_encode($response_data);
7715 return true;
7716 }
7717 }
7718 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7719 try {
7720 // Get bot ID from session or request
7721 $bot_id = $this->get_current_bot_id($session_id);
7722
7723 // Get system prompt instructions using centralized function
7724 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7725
7726 // Ensure conversation_history is an array
7727 if (!is_array($conversation_history)) {
7728 $conversation_history = array();
7729 }
7730
7731 // Format conversation history for DeepSeek
7732 $formatted_conversation = array();
7733
7734 $formatted_conversation[] = array(
7735 'role' => 'system',
7736 'content' => $system_prompt_instructions . " " . $relevant_content
7737 );
7738
7739 foreach ($conversation_history as $message) {
7740 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7741 $role = $message['role'];
7742 if ($role === 'bot' || $role === 'agent') {
7743 $role = 'assistant';
7744 }
7745 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7746 $role = 'user';
7747 }
7748 $formatted_conversation[] = array(
7749 'role' => $role,
7750 'content' => $message['content']
7751 );
7752 }
7753 }
7754
7755 // Check if we can actually stream
7756 if (headers_sent() || !function_exists('curl_init')) {
7757 // Fallback to regular response with testing data
7758 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
7759 $regular_response = $this->mxchat_generate_response_deepseek(
7760 $selected_model,
7761 $deepseek_api_key,
7762 $conversation_history,
7763 $relevant_content
7764 );
7765
7766 // Save bot response to transcript
7767 if (!empty($regular_response) && !empty($session_id)) {
7768 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7769 }
7770
7771 $response_data = [
7772 'text' => $regular_response,
7773 'html' => '',
7774 'session_id' => $session_id
7775 ];
7776
7777 if ($testing_data !== null) {
7778 $response_data['testing_data'] = $testing_data;
7779 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
7780 }
7781
7782 header('Content-Type: application/json');
7783 echo json_encode($response_data);
7784 return true;
7785 }
7786
7787 // Prepare the request body with stream: true
7788 $body = json_encode([
7789 'model' => $selected_model,
7790 'messages' => $formatted_conversation,
7791 'temperature' => 0.8,
7792 'stream' => true
7793 ]);
7794
7795 // Setup streaming headers now that we know we're actually streaming
7796 $this->setup_streaming_headers();
7797
7798 // Use cURL for streaming support
7799 $ch = curl_init();
7800 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
7801 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7802 curl_setopt($ch, CURLOPT_POST, true);
7803 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7804 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7805 'Content-Type: application/json',
7806 'Authorization: Bearer ' . $deepseek_api_key
7807 ));
7808 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7809 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7810
7811 $full_response = ''; // Accumulate full response for saving
7812 $stream_started = false;
7813 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7814
7815 // Buffer control for real-time streaming
7816 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7817 // Send testing data as the first event if available
7818 if (!$stream_started && $testing_data !== null) {
7819 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7820 flush();
7821 $stream_started = true;
7822 //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
7823 }
7824
7825 // CRITICAL FIX: Append new data to buffer
7826 $buffer .= $data;
7827
7828 // Process complete lines only
7829 $lines = explode("\n", $buffer);
7830
7831 // CRITICAL FIX: Keep the last incomplete line in the buffer
7832 // The last element might be incomplete, so keep it in buffer
7833 $buffer = array_pop($lines);
7834
7835 foreach ($lines as $line) {
7836 // Skip empty lines
7837 if (trim($line) === '') {
7838 continue;
7839 }
7840
7841 // Only process lines that start with "data: "
7842 if (strpos($line, 'data: ') !== 0) {
7843 continue;
7844 }
7845
7846 $json_str = substr($line, 6); // Remove 'data: ' prefix
7847
7848 if (trim($json_str) === '[DONE]') {
7849 echo "data: [DONE]\n\n";
7850 flush();
7851 continue;
7852 }
7853
7854 // Try to decode JSON
7855 $json = json_decode(trim($json_str), true);
7856 if ($json && isset($json['choices'][0]['delta']['content'])) {
7857 $content = $json['choices'][0]['delta']['content'];
7858 $full_response .= $content; // Accumulate the full response
7859
7860 // Send as SSE format
7861 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7862 flush();
7863 }
7864 }
7865
7866 return strlen($data);
7867 });
7868
7869 $response = curl_exec($ch);
7870 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7871
7872 if (curl_errno($ch) || $http_code !== 200) {
7873 $curl_error = curl_error($ch);
7874 curl_close($ch);
7875
7876 // Log the specific error for debugging
7877 //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
7878
7879 // Fallback to regular response
7880 $regular_response = $this->mxchat_generate_response_deepseek(
7881 $selected_model,
7882 $deepseek_api_key,
7883 $conversation_history,
7884 $relevant_content
7885 );
7886
7887 // Handle error response from regular function
7888 if (is_array($regular_response) && isset($regular_response['error'])) {
7889 if ($testing_data !== null) {
7890 $regular_response['testing_data'] = $testing_data;
7891 }
7892 header('Content-Type: application/json');
7893 echo json_encode($regular_response);
7894 return true;
7895 }
7896
7897 $response_data = [
7898 'text' => $regular_response,
7899 'html' => '',
7900 'session_id' => $session_id
7901 ];
7902
7903 if ($testing_data !== null) {
7904 $response_data['testing_data'] = $testing_data;
7905 //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
7906 }
7907
7908 header('Content-Type: application/json');
7909 echo json_encode($response_data);
7910 return true;
7911 }
7912
7913 curl_close($ch);
7914
7915 // Save the complete response to maintain chat persistence
7916 if (!empty($full_response) && !empty($session_id)) {
7917 // Prepare RAG context for streaming response
7918 $rag_context_for_storage = null;
7919 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7920 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7921
7922 if ($has_rag_data || $has_action_data) {
7923 $rag_context_for_storage = [];
7924
7925 if ($has_rag_data) {
7926 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7927 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7928 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7929 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7930 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7931 }
7932
7933 if ($has_action_data) {
7934 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7935 }
7936 }
7937 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7938 }
7939
7940 return true; // Indicate streaming completed successfully
7941
7942 } catch (Exception $e) {
7943 //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
7944
7945 // Fallback to regular response
7946 $regular_response = $this->mxchat_generate_response_deepseek(
7947 $selected_model,
7948 $deepseek_api_key,
7949 $conversation_history,
7950 $relevant_content
7951 );
7952
7953 // Handle error response from regular function
7954 if (is_array($regular_response) && isset($regular_response['error'])) {
7955 if ($testing_data !== null) {
7956 $regular_response['testing_data'] = $testing_data;
7957 }
7958 header('Content-Type: application/json');
7959 echo json_encode($regular_response);
7960 return true;
7961 }
7962
7963 $response_data = [
7964 'text' => $regular_response,
7965 'html' => '',
7966 'session_id' => $session_id
7967 ];
7968
7969 if ($testing_data !== null) {
7970 $response_data['testing_data'] = $testing_data;
7971 //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
7972 }
7973
7974 header('Content-Type: application/json');
7975 echo json_encode($response_data);
7976 return true;
7977 }
7978 }
7979
7980
7981 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
7982 try {
7983 if (!is_array($conversation_history)) {
7984 $conversation_history = array();
7985 }
7986
7987 $bot_id = $this->get_current_bot_id('');
7988 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7989
7990 $formatted_conversation = array();
7991
7992 $formatted_conversation[] = array(
7993 'role' => 'system',
7994 'content' => $system_prompt_instructions . " " . $relevant_content
7995 );
7996
7997 foreach ($conversation_history as $message) {
7998 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7999 $role = $message['role'];
8000
8001 if ($role === 'bot' || $role === 'agent') {
8002 $role = 'assistant';
8003 }
8004 if (!in_array($role, ['system', 'assistant', 'user'])) {
8005 $role = 'user';
8006 }
8007
8008 $formatted_conversation[] = array(
8009 'role' => $role,
8010 'content' => $message['content']
8011 );
8012 }
8013 }
8014
8015 $body = json_encode([
8016 'model' => $selected_model,
8017 'messages' => $formatted_conversation,
8018 'temperature' => 1,
8019 ]);
8020
8021 $args = [
8022 'body' => $body,
8023 'headers' => [
8024 'Content-Type' => 'application/json',
8025 'Authorization' => 'Bearer ' . $openrouter_api_key,
8026 'HTTP-Referer' => home_url(),
8027 'X-Title' => get_bloginfo('name'),
8028 ],
8029 'timeout' => 60,
8030 'redirection' => 5,
8031 'blocking' => true,
8032 'httpversion' => '1.0',
8033 'sslverify' => true,
8034 ];
8035
8036 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', $args);
8037
8038 if (is_wp_error($response)) {
8039 $error_message = $response->get_error_message();
8040 return [
8041 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
8042 'error_code' => 'openrouter_connection_error',
8043 'provider' => 'openrouter'
8044 ];
8045 }
8046
8047 $status_code = wp_remote_retrieve_response_code($response);
8048 if ($status_code !== 200) {
8049 $response_body = wp_remote_retrieve_body($response);
8050 $decoded_response = json_decode($response_body, true);
8051
8052 $error_message = isset($decoded_response['error']['message'])
8053 ? $decoded_response['error']['message']
8054 : 'HTTP Error ' . $status_code;
8055
8056 return [
8057 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
8058 'error_code' => 'openrouter_api_error',
8059 'provider' => 'openrouter',
8060 'status_code' => $status_code
8061 ];
8062 }
8063
8064 $response_body = wp_remote_retrieve_body($response);
8065 $decoded_response = json_decode($response_body, true);
8066
8067 if (isset($decoded_response['choices'][0]['message']['content'])) {
8068 return trim($decoded_response['choices'][0]['message']['content']);
8069 } else {
8070 return [
8071 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
8072 'error_code' => 'openrouter_response_format_error',
8073 'provider' => 'openrouter'
8074 ];
8075 }
8076 } catch (Exception $e) {
8077 return [
8078 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
8079 'error_code' => 'openrouter_exception',
8080 'provider' => 'openrouter'
8081 ];
8082 }
8083 }
8084 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
8085
8086 // Get bot ID from session or request
8087 $bot_id = $this->get_current_bot_id($session_id);
8088
8089 // Get system prompt instructions using centralized function
8090 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8091
8092 // Clean and validate conversation history
8093 foreach ($conversation_history as &$message) {
8094 // Convert bot and agent roles to assistant
8095 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
8096 $message['role'] = 'assistant';
8097 }
8098
8099 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
8100 if (!in_array($message['role'], ['assistant', 'user'])) {
8101 $message['role'] = 'user';
8102 }
8103
8104 // Ensure content field exists
8105 if (!isset($message['content']) || empty($message['content'])) {
8106 $message['content'] = '';
8107 }
8108
8109 // Remove any unsupported fields
8110 $message = array_intersect_key($message, array_flip(['role', 'content']));
8111 }
8112
8113 // Add relevant content as the latest user message
8114 $conversation_history[] = [
8115 'role' => 'user',
8116 'content' => $relevant_content
8117 ];
8118
8119 // Build request body
8120 $body = json_encode([
8121 'model' => $selected_model,
8122 'max_tokens' => 1000,
8123 'temperature' => 0.8,
8124 'messages' => $conversation_history,
8125 'system' => $system_prompt_instructions
8126 ]);
8127
8128 // Set up API request
8129 $args = [
8130 'body' => $body,
8131 'headers' => [
8132 'Content-Type' => 'application/json',
8133 'x-api-key' => $claude_api_key,
8134 'anthropic-version' => '2023-06-01'
8135 ],
8136 'timeout' => 60,
8137 'redirection' => 5,
8138 'blocking' => true,
8139 'httpversion' => '1.0',
8140 'sslverify' => true,
8141 ];
8142
8143 // Make API request
8144 $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
8145
8146 // Check for WordPress errors
8147 if (is_wp_error($response)) {
8148 //error_log("Claude API request error: " . $response->get_error_message());
8149 return "Sorry, there was an error connecting to the API.";
8150 }
8151
8152 // Check HTTP response code
8153 $http_code = wp_remote_retrieve_response_code($response);
8154 if ($http_code !== 200) {
8155 $error_body = wp_remote_retrieve_body($response);
8156 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
8157
8158 // Try to extract error message from response
8159 $error_data = json_decode($error_body, true);
8160 $error_message = isset($error_data['error']['message']) ?
8161 $error_data['error']['message'] :
8162 "HTTP error " . $http_code;
8163
8164 return "Sorry, the API returned an error: " . $error_message;
8165 }
8166
8167 // Parse response
8168 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8169
8170 // Check for JSON decode errors
8171 if (json_last_error() !== JSON_ERROR_NONE) {
8172 //error_log("Claude API JSON decode error: " . json_last_error_msg());
8173 return "Sorry, there was an error processing the API response.";
8174 }
8175
8176 // Extract and validate response content
8177 if (isset($response_body['content']) &&
8178 is_array($response_body['content']) &&
8179 !empty($response_body['content']) &&
8180 isset($response_body['content'][0]['text'])) {
8181 return trim($response_body['content'][0]['text']);
8182 }
8183
8184 // Log unexpected response format
8185 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
8186 return "Sorry, I received an unexpected response format from the API.";
8187 }
8188 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
8189 try {
8190 // Ensure conversation_history is an array
8191 if (!is_array($conversation_history)) {
8192 $conversation_history = array();
8193 }
8194
8195 // Get bot ID from session or request
8196 $bot_id = $this->get_current_bot_id('');
8197
8198 // Get system prompt instructions using centralized function
8199 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8200
8201 // Create a new array for the formatted conversation
8202 $formatted_conversation = array();
8203
8204 // Add system message first
8205 $formatted_conversation[] = array(
8206 'role' => 'system',
8207 'content' => $system_prompt_instructions . " " . $relevant_content
8208 );
8209
8210 // Add the rest of the conversation history
8211 foreach ($conversation_history as $message) {
8212 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8213 $role = $message['role'];
8214
8215 // Convert roles to supported format
8216 if ($role === 'bot' || $role === 'agent') {
8217 $role = 'assistant';
8218 }
8219 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8220 $role = 'user';
8221 }
8222
8223 $formatted_conversation[] = array(
8224 'role' => $role,
8225 'content' => $message['content']
8226 );
8227 }
8228 }
8229
8230 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8231 $is_gpt5_model = (
8232 strpos($selected_model, 'gpt-5') === 0 ||
8233 $selected_model === 'gpt-5.2' ||
8234 $selected_model === 'gpt-5.1-2025-11-13' ||
8235 $selected_model === 'gpt-5' ||
8236 $selected_model === 'gpt-5-mini' ||
8237 $selected_model === 'gpt-5-nano'
8238 );
8239
8240 // Build request body with optimal settings for fast responses
8241 $request_body = [
8242 'model' => $selected_model,
8243 'messages' => $formatted_conversation,
8244 'temperature' => 1,
8245 'stream' => false
8246 ];
8247
8248 // Add reasoning_effort only for GPT-5 models that support it
8249 // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
8250 if ($is_gpt5_model && $selected_model !== 'gpt-5.2' && $selected_model !== 'gpt-5.1-chat-latest') {
8251 // GPT-5.1 uses 'low' instead of 'minimal'
8252 if ($selected_model === 'gpt-5.1-2025-11-13') {
8253 $request_body['reasoning_effort'] = 'low';
8254 } else {
8255 $request_body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
8256 }
8257 }
8258
8259 $body = json_encode($request_body);
8260
8261 $args = [
8262 'body' => $body,
8263 'headers' => [
8264 'Content-Type' => 'application/json',
8265 'Authorization' => 'Bearer ' . $api_key,
8266 ],
8267 'timeout' => 60,
8268 'redirection' => 5,
8269 'blocking' => true,
8270 'httpversion' => '1.0',
8271 'sslverify' => true,
8272 ];
8273
8274 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
8275
8276 if (is_wp_error($response)) {
8277 $error_message = $response->get_error_message();
8278 return [
8279 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
8280 'error_code' => 'openai_connection_error',
8281 'provider' => 'openai'
8282 ];
8283 }
8284
8285 $status_code = wp_remote_retrieve_response_code($response);
8286 if ($status_code !== 200) {
8287 $response_body = wp_remote_retrieve_body($response);
8288 $decoded_response = json_decode($response_body, true);
8289
8290 $error_message = isset($decoded_response['error']['message'])
8291 ? $decoded_response['error']['message']
8292 : 'HTTP Error ' . $status_code;
8293
8294 $error_type = isset($decoded_response['error']['type'])
8295 ? $decoded_response['error']['type']
8296 : 'unknown';
8297
8298 // Handle specific error types
8299 switch ($error_type) {
8300 case 'invalid_request_error':
8301 if (strpos($error_message, 'API key') !== false) {
8302 return [
8303 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
8304 'error_code' => 'openai_invalid_api_key',
8305 'provider' => 'openai'
8306 ];
8307 }
8308 break;
8309
8310 case 'authentication_error':
8311 return [
8312 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
8313 'error_code' => 'openai_auth_error',
8314 'provider' => 'openai'
8315 ];
8316
8317 case 'rate_limit_exceeded':
8318 return [
8319 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
8320 'error_code' => 'openai_rate_limit',
8321 'provider' => 'openai'
8322 ];
8323
8324 case 'quota_exceeded':
8325 return [
8326 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
8327 'error_code' => 'openai_quota_exceeded',
8328 'provider' => 'openai'
8329 ];
8330 }
8331
8332 // Generic error fallback
8333 return [
8334 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
8335 'error_code' => 'openai_api_error',
8336 'provider' => 'openai',
8337 'status_code' => $status_code
8338 ];
8339 }
8340
8341 $response_body = wp_remote_retrieve_body($response);
8342 $decoded_response = json_decode($response_body, true);
8343
8344 if (isset($decoded_response['choices'][0]['message']['content'])) {
8345 return trim($decoded_response['choices'][0]['message']['content']);
8346 } else {
8347 return [
8348 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
8349 'error_code' => 'openai_response_format_error',
8350 'provider' => 'openai'
8351 ];
8352 }
8353 } catch (Exception $e) {
8354 return [
8355 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
8356 'error_code' => 'openai_exception',
8357 'provider' => 'openai'
8358 ];
8359 }
8360 }
8361
8362 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
8363 try {
8364 // Get bot ID from session or request
8365 $bot_id = $this->get_current_bot_id($session_id);
8366
8367 // Get system prompt instructions using centralized function
8368 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8369
8370 // Add system prompt to relevant content
8371 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8372
8373 // Prepend system instructions to the conversation history
8374 array_unshift($conversation_history, [
8375 'role' => 'system',
8376 'content' => "Here are your instructions: " . $content_with_instructions
8377 ]);
8378
8379 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
8380 foreach ($conversation_history as &$message) {
8381 if ($message['role'] === 'bot') {
8382 $message['role'] = 'assistant';
8383 } elseif ($message['role'] === 'agent') {
8384 // Tag the message as coming from a live agent
8385 $message['role'] = 'assistant';
8386 if (!isset($message['metadata'])) {
8387 $message['metadata'] = ['source' => 'live_agent'];
8388 }
8389 }
8390
8391 // Ensure all roles are valid
8392 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
8393 $message['role'] = 'user'; // Default to 'user'
8394 }
8395 }
8396
8397 // Build the request body
8398 $body = json_encode([
8399 'model' => $selected_model,
8400 'messages' => $conversation_history,
8401 'temperature' => 0.8,
8402 'stream' => false
8403 ]);
8404
8405 // Set up the API request
8406 $args = [
8407 'body' => $body,
8408 'headers' => [
8409 'Content-Type' => 'application/json',
8410 'Authorization' => 'Bearer ' . $xai_api_key,
8411 ],
8412 'timeout' => 60,
8413 'redirection' => 5,
8414 'blocking' => true,
8415 'httpversion' => '1.0',
8416 'sslverify' => true,
8417 ];
8418
8419 // Make the API request
8420 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
8421
8422 // Process the response
8423 if (is_wp_error($response)) {
8424 $error_message = $response->get_error_message();
8425 //error_log('X.AI API Error: ' . $error_message);
8426 return [
8427 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
8428 'error_code' => 'xai_connection_error',
8429 'provider' => 'xai'
8430 ];
8431 }
8432
8433 $status_code = wp_remote_retrieve_response_code($response);
8434 if ($status_code !== 200) {
8435 $response_body = wp_remote_retrieve_body($response);
8436 $decoded_response = json_decode($response_body, true);
8437
8438 // Log the full response for debugging
8439 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
8440
8441 // Extract error message from X.AI's specific format
8442 $error_message = '';
8443
8444 // Check for direct error string (as seen in your logs)
8445 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
8446 $error_message = $decoded_response['error'];
8447 }
8448 // Check for nested error object (OpenAI style)
8449 elseif (isset($decoded_response['error']['message'])) {
8450 $error_message = $decoded_response['error']['message'];
8451 }
8452 // Check for top-level message
8453 elseif (isset($decoded_response['message'])) {
8454 $error_message = $decoded_response['message'];
8455 }
8456 // Fallback
8457 else {
8458 $error_message = 'HTTP Error ' . $status_code;
8459 }
8460
8461 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
8462
8463 // Check for API key errors using string matching
8464 if (stripos($error_message, 'api key') !== false ||
8465 stripos($error_message, 'incorrect api key') !== false ||
8466 stripos($error_message, 'invalid api key') !== false) {
8467 return [
8468 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
8469 'error_code' => 'xai_invalid_api_key',
8470 'provider' => 'xai'
8471 ];
8472 }
8473
8474 // Authentication errors
8475 if ($status_code === 401 || $status_code === 403 ||
8476 stripos($error_message, 'auth') !== false) {
8477 return [
8478 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
8479 'error_code' => 'xai_auth_error',
8480 'provider' => 'xai'
8481 ];
8482 }
8483
8484 // Model errors
8485 if (stripos($error_message, 'model') !== false) {
8486 return [
8487 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
8488 'error_code' => 'xai_invalid_model',
8489 'provider' => 'xai'
8490 ];
8491 }
8492
8493 // Rate limit errors
8494 if ($status_code === 429 ||
8495 stripos($error_message, 'rate') !== false ||
8496 stripos($error_message, 'limit') !== false) {
8497 return [
8498 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
8499 'error_code' => 'xai_rate_limit',
8500 'provider' => 'xai'
8501 ];
8502 }
8503
8504 // Quota errors
8505 if (stripos($error_message, 'quota') !== false ||
8506 stripos($error_message, 'billing') !== false) {
8507 return [
8508 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
8509 'error_code' => 'xai_quota_exceeded',
8510 'provider' => 'xai'
8511 ];
8512 }
8513
8514 // Server errors
8515 if ($status_code >= 500) {
8516 return [
8517 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
8518 'error_code' => 'xai_service_unavailable',
8519 'provider' => 'xai'
8520 ];
8521 }
8522
8523 // Generic error fallback with the actual error message
8524 return [
8525 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
8526 'error_code' => 'xai_api_error',
8527 'provider' => 'xai',
8528 'status_code' => $status_code
8529 ];
8530 }
8531
8532 $response_body = wp_remote_retrieve_body($response);
8533 $decoded_response = json_decode($response_body, true);
8534
8535 if (isset($decoded_response['choices'][0]['message']['content'])) {
8536 return trim($decoded_response['choices'][0]['message']['content']);
8537 } else {
8538 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
8539 return [
8540 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
8541 'error_code' => 'xai_response_format_error',
8542 'provider' => 'xai'
8543 ];
8544 }
8545 } catch (Exception $e) {
8546 //error_log('X.AI Exception: ' . $e->getMessage());
8547 return [
8548 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
8549 'error_code' => 'xai_exception',
8550 'provider' => 'xai'
8551 ];
8552 }
8553
8554
8555 }
8556 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
8557 try {
8558 // Ensure conversation_history is an array
8559 if (!is_array($conversation_history)) {
8560 $conversation_history = array();
8561 }
8562
8563 // Get bot ID from session or request
8564 $bot_id = $this->get_current_bot_id($session_id);
8565
8566 // Get system prompt instructions using centralized function
8567 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8568
8569 // Create a new array for the formatted conversation
8570 $formatted_conversation = array();
8571
8572 // Add system message first
8573 $formatted_conversation[] = array(
8574 'role' => 'system',
8575 'content' => $system_prompt_instructions . " " . $relevant_content
8576 );
8577
8578 // Add the rest of the conversation history
8579 foreach ($conversation_history as $message) {
8580 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8581 $role = $message['role'];
8582
8583 // Convert roles to supported format
8584 if ($role === 'bot' || $role === 'agent') {
8585 $role = 'assistant';
8586 }
8587 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8588 $role = 'user';
8589 }
8590
8591 $formatted_conversation[] = array(
8592 'role' => $role,
8593 'content' => $message['content']
8594 );
8595 }
8596 }
8597
8598 $body = json_encode([
8599 'model' => $selected_model,
8600 'messages' => $formatted_conversation,
8601 'temperature' => 0.8,
8602 'stream' => false
8603 ]);
8604
8605 $args = [
8606 'body' => $body,
8607 'headers' => [
8608 'Content-Type' => 'application/json',
8609 'Authorization' => 'Bearer ' . $deepseek_api_key,
8610 ],
8611 'timeout' => 60,
8612 'redirection' => 5,
8613 'blocking' => true,
8614 'httpversion' => '1.0',
8615 'sslverify' => true,
8616 ];
8617
8618 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
8619
8620 if (is_wp_error($response)) {
8621 $error_message = $response->get_error_message();
8622 //error_log('DeepSeek API Error: ' . $error_message);
8623 return [
8624 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
8625 'error_code' => 'deepseek_connection_error',
8626 'provider' => 'deepseek'
8627 ];
8628 }
8629
8630 $status_code = wp_remote_retrieve_response_code($response);
8631 if ($status_code !== 200) {
8632 $response_body = wp_remote_retrieve_body($response);
8633 $decoded_response = json_decode($response_body, true);
8634
8635 $error_message = isset($decoded_response['error']['message'])
8636 ? $decoded_response['error']['message']
8637 : 'HTTP Error ' . $status_code;
8638
8639 $error_type = isset($decoded_response['error']['type'])
8640 ? $decoded_response['error']['type']
8641 : 'unknown';
8642
8643 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
8644
8645 // Handle specific error types
8646 switch ($status_code) {
8647 case 401:
8648 return [
8649 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
8650 'error_code' => 'deepseek_auth_error',
8651 'provider' => 'deepseek'
8652 ];
8653
8654 case 400:
8655 if (strpos($error_message, 'API key') !== false) {
8656 return [
8657 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
8658 'error_code' => 'deepseek_invalid_api_key',
8659 'provider' => 'deepseek'
8660 ];
8661 }
8662 break;
8663
8664 case 429:
8665 if (strpos($error_message, 'quota') !== false) {
8666 return [
8667 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
8668 'error_code' => 'deepseek_quota_exceeded',
8669 'provider' => 'deepseek'
8670 ];
8671 } else {
8672 return [
8673 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
8674 'error_code' => 'deepseek_rate_limit',
8675 'provider' => 'deepseek'
8676 ];
8677 }
8678
8679 case 500:
8680 case 502:
8681 case 503:
8682 case 504:
8683 return [
8684 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
8685 'error_code' => 'deepseek_service_unavailable',
8686 'provider' => 'deepseek'
8687 ];
8688 }
8689
8690 // Generic error fallback
8691 return [
8692 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
8693 'error_code' => 'deepseek_api_error',
8694 'provider' => 'deepseek',
8695 'status_code' => $status_code
8696 ];
8697 }
8698
8699 $response_body = wp_remote_retrieve_body($response);
8700 $decoded_response = json_decode($response_body, true);
8701
8702 if (isset($decoded_response['choices'][0]['message']['content'])) {
8703 return trim($decoded_response['choices'][0]['message']['content']);
8704 } else {
8705 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
8706 return [
8707 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
8708 'error_code' => 'deepseek_response_format_error',
8709 'provider' => 'deepseek'
8710 ];
8711 }
8712 } catch (Exception $e) {
8713 //error_log('DeepSeek Exception: ' . $e->getMessage());
8714 return [
8715 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
8716 'error_code' => 'deepseek_exception',
8717 'provider' => 'deepseek'
8718 ];
8719 }
8720 }
8721 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
8722 // Get bot ID from session or request
8723 $bot_id = $this->get_current_bot_id($session_id);
8724
8725 // Get system prompt instructions using centralized function
8726 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8727
8728 // Add system prompt to relevant content
8729 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8730
8731 // Format messages for Gemini API
8732 $formatted_messages = [];
8733
8734 // Add system message as the first user message with role prefix
8735 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
8736 $formatted_messages[] = [
8737 'role' => 'user',
8738 'parts' => [
8739 ['text' => "[System Instructions] " . $content_with_instructions]
8740 ]
8741 ];
8742
8743 // Add model response to acknowledge system instructions
8744 $formatted_messages[] = [
8745 'role' => 'model',
8746 'parts' => [
8747 ['text' => "I understand and will follow these instructions."]
8748 ]
8749 ];
8750
8751 // Process the rest of the conversation history
8752 $current_role = null;
8753 $current_parts = [];
8754
8755 foreach ($conversation_history as $message) {
8756 // Skip the first system message as we already handled it
8757 if ($message['role'] === 'system') {
8758 continue;
8759 }
8760
8761 // Map roles to Gemini format
8762 $gemini_role = '';
8763 if ($message['role'] === 'user') {
8764 $gemini_role = 'user';
8765 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
8766 $gemini_role = 'model';
8767 } else {
8768 // Skip unsupported roles
8769 continue;
8770 }
8771
8772 // If we have a new role, add the previous message
8773 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
8774 $formatted_messages[] = [
8775 'role' => $current_role,
8776 'parts' => $current_parts
8777 ];
8778 $current_parts = [];
8779 }
8780
8781 // Set current role and add text to parts
8782 $current_role = $gemini_role;
8783 $current_parts[] = ['text' => $message['content']];
8784 }
8785
8786 // Add the last message if there's content
8787 if ($current_role !== null && !empty($current_parts)) {
8788 $formatted_messages[] = [
8789 'role' => $current_role,
8790 'parts' => $current_parts
8791 ];
8792 }
8793
8794 // Build the request body
8795 $body = json_encode([
8796 'contents' => $formatted_messages,
8797 'generationConfig' => [
8798 'temperature' => 0.7,
8799 'topP' => 0.95,
8800 'topK' => 40,
8801 'maxOutputTokens' => 8192,
8802 ],
8803 'safetySettings' => [
8804 [
8805 'category' => 'HARM_CATEGORY_HARASSMENT',
8806 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8807 ],
8808 [
8809 'category' => 'HARM_CATEGORY_HATE_SPEECH',
8810 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8811 ],
8812 [
8813 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
8814 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8815 ],
8816 [
8817 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
8818 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8819 ]
8820 ]
8821 ]);
8822
8823 // Prepare the API endpoint
8824 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models
8825 $api_version = (strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
8826 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
8827
8828 // Set up the API request
8829 $args = [
8830 'body' => $body,
8831 'headers' => [
8832 'Content-Type' => 'application/json',
8833 ],
8834 'timeout' => 60,
8835 'redirection' => 5,
8836 'blocking' => true,
8837 'httpversion' => '1.0',
8838 'sslverify' => true,
8839 ];
8840
8841 // Make the API request
8842 $response = wp_remote_post($api_endpoint, $args);
8843
8844 // Process the response
8845 if (is_wp_error($response)) {
8846 return "Sorry, there was an error processing your request: " . $response->get_error_message();
8847 }
8848
8849 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8850
8851 // Handle potential errors in the response
8852 if (isset($response_body['error'])) {
8853 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
8854 return "Sorry, there was an error with the Gemini API: " .
8855 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
8856 }
8857
8858 // Extract the response text
8859 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
8860 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
8861 } else {
8862 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
8863 return "Sorry, I couldn't process that request. The response format was unexpected.";
8864 }
8865 }
8866
8867
8868 public function test_streaming_request() {
8869 $options = get_option('mxchat_options', []);
8870 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
8871
8872 // Detect provider from model prefix
8873 $provider = strtolower(explode('-', $model)[0]);
8874
8875 $sample_prompt = 'Hello! Can you stream this response back to me?';
8876 $messages = [['role' => 'user', 'content' => $sample_prompt]];
8877 $headers = [];
8878 $body = [];
8879 $url = '';
8880 $api_key = '';
8881
8882 switch ($provider) {
8883 case 'gpt':
8884 case 'o1':
8885 $api_key = $options['api_key'] ?? '';
8886 if (empty($api_key)) return '❌ Missing API key for OpenAI';
8887 $url = 'https://api.openai.com/v1/chat/completions';
8888 $headers = [
8889 'Content-Type: application/json',
8890 'Authorization: Bearer ' . $api_key
8891 ];
8892 $body = [
8893 'model' => $model,
8894 'messages' => $messages,
8895 'stream' => true
8896 ];
8897 break;
8898
8899 case 'claude':
8900 $api_key = $options['claude_api_key'] ?? '';
8901 if (empty($api_key)) return '❌ Missing API key for Claude';
8902 $url = 'https://api.anthropic.com/v1/messages';
8903 $headers = [
8904 'Content-Type: application/json',
8905 'x-api-key: ' . $api_key,
8906 'anthropic-version: 2023-06-01'
8907 ];
8908 $body = [
8909 'model' => $model,
8910 'messages' => $messages,
8911 'max_tokens' => 100,
8912 'stream' => true
8913 ];
8914 break;
8915
8916 case 'grok':
8917 $api_key = $options['xai_api_key'] ?? '';
8918 if (empty($api_key)) return '❌ Missing API key for X.AI';
8919 $url = 'https://api.x.ai/v1/chat/completions';
8920 $headers = [
8921 'Content-Type: application/json',
8922 'Authorization: Bearer ' . $api_key
8923 ];
8924 $body = [
8925 'model' => $model,
8926 'messages' => $messages,
8927 'stream' => true
8928 ];
8929 break;
8930
8931 case 'deepseek':
8932 if (empty($deepseek_api_key)) {
8933 $error_response = [
8934 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
8935 'error_code' => 'missing_deepseek_api_key'
8936 ];
8937 if ($testing_data !== null) {
8938 $error_response['testing_data'] = $testing_data;
8939 }
8940 return $error_response;
8941 }
8942 if ($streaming) {
8943 return $this->mxchat_generate_response_deepseek_stream(
8944 $selected_model,
8945 $deepseek_api_key,
8946 $conversation_history,
8947 $relevant_content,
8948 $session_id,
8949 $testing_data // Pass testing data
8950 );
8951 } else {
8952 $response = $this->mxchat_generate_response_deepseek(
8953 $selected_model,
8954 $deepseek_api_key,
8955 $conversation_history,
8956 $relevant_content
8957 );
8958 }
8959 break;
8960
8961 case 'gemini':
8962 $api_key = $options['gemini_api_key'] ?? '';
8963 if (empty($api_key)) return '❌ Missing API key for Gemini';
8964 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
8965 $headers = ['Content-Type: application/json'];
8966 $body = [
8967 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
8968 'generationConfig' => ['temperature' => 0.7]
8969 ];
8970 break;
8971
8972 default:
8973 return '❌ Unsupported provider: ' . $provider;
8974 }
8975
8976 // Do the actual streaming test
8977 $ch = curl_init($url);
8978 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
8979 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
8980 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
8981 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
8982 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8983
8984 $response = curl_exec($ch);
8985 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8986 $error = curl_error($ch);
8987 curl_close($ch);
8988
8989 if ($error) return "❌ cURL error: $error";
8990 if ($http_code !== 200) {
8991 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
8992 return "❌ HTTP $http_code: $error_message";
8993 }
8994
8995 return true;
8996 }
8997
8998 public function mxchat_dismiss_pre_chat_message() {
8999 // Get and sanitize the user identifier
9000 $user_id = $this->mxchat_get_user_identifier();
9001 $user_id = sanitize_key($user_id);
9002
9003 // Set a transient to track that the user has dismissed the pre-chat message
9004 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9005 set_transient($transient_key, true, DAY_IN_SECONDS);
9006
9007 wp_send_json_success();
9008 }
9009
9010 public function mxchat_check_pre_chat_message_status() {
9011 // Get and sanitize the user identifier
9012 $user_id = $this->mxchat_get_user_identifier();
9013 $user_id = sanitize_key($user_id);
9014
9015 // Check if the transient exists (i.e., if the message was dismissed)
9016 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9017 $dismissed = get_transient($transient_key);
9018
9019 // Log the result to see if it's being set correctly
9020 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
9021
9022 if ($dismissed) {
9023 wp_send_json_success(['dismissed' => true]);
9024 } else {
9025 wp_send_json_success(['dismissed' => false]);
9026 }
9027
9028 wp_die();
9029 }
9030
9031 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
9032 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
9033 return 0;
9034 }
9035
9036 $dotProduct = array_sum(array_map(function ($a, $b) {
9037 return $a * $b;
9038 }, $vectorA, $vectorB));
9039 $normA = sqrt(array_sum(array_map(function ($a) {
9040 return $a * $a;
9041 }, $vectorA)));
9042 $normB = sqrt(array_sum(array_map(function ($b) {
9043 return $b * $b;
9044 }, $vectorB)));
9045
9046 if ($normA == 0 || $normB == 0) {
9047 return 0;
9048 }
9049
9050 return $dotProduct / ($normA * $normB);
9051 }
9052
9053
9054 public function mxchat_enqueue_scripts_styles() {
9055 // Fetch options from the database first to check loading strategy
9056 $this->options = get_option('mxchat_options');
9057 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9058
9059 // Always enqueue CSS immediately
9060 wp_enqueue_style(
9061 'mxchat-chat-css',
9062 plugin_dir_url(__FILE__) . '../css/chat-style.css',
9063 array(),
9064 MXCHAT_VERSION
9065 );
9066
9067 // Handle script loading based on strategy
9068 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9069 // Enqueue the script normally
9070 wp_enqueue_script(
9071 'mxchat-chat-js',
9072 plugin_dir_url(__FILE__) . '../js/chat-script.js',
9073 array('jquery'),
9074 MXCHAT_VERSION,
9075 true
9076 );
9077
9078 // Add defer attribute if strategy is 'defer'
9079 if ($loading_strategy === 'defer') {
9080 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
9081 }
9082 } else {
9083 // For delay or interaction-based loading, we'll use a custom loader
9084 // Don't enqueue the main script - we'll load it dynamically
9085 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
9086 }
9087 $prompts_options = get_option('mxchat_prompts_options', array());
9088
9089 // Check if AI theme is active - if so, skip inline colors in JavaScript
9090 $theme_options = get_option('mxchat_theme_options', array());
9091 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9092 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9093 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9094
9095 // Prepare settings for JavaScript
9096 $style_settings = array(
9097 'ajax_url' => admin_url('admin-ajax.php'),
9098 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9099 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9100 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9101 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9102 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9103 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9104 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9105 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9106 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9107 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9108 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9109 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9110 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9111 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9112 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9113 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9114 'icon_color' => $this->options['icon_color'] ?? '#fff',
9115 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9116 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9117 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9118 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9119 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9120 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9121 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9122 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9123 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9124 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9125 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
9126 'initial_email_state' => null, // Also fixed this undefined variable
9127 'skip_email_check' => true,
9128 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9129 'skip_inline_colors' => $skip_inline_colors,
9130 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9131 );
9132
9133 // For normal/defer loading, use wp_localize_script
9134 // For delayed loading, we store settings in a transient to be output inline
9135 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9136 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
9137 } else {
9138 // Store settings for the delayed loader to use
9139 set_transient('mxchat_delayed_settings_' . get_current_user_id(), $style_settings, 60);
9140 }
9141 }
9142
9143 /**
9144 * Output the delayed script loader for performance optimization
9145 */
9146 public function mxchat_output_delayed_script_loader() {
9147 $this->options = get_option('mxchat_options');
9148 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9149 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
9150
9151 // Get the stored settings
9152 $prompts_options = get_option('mxchat_prompts_options', array());
9153 $theme_options = get_option('mxchat_theme_options', array());
9154 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9155 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9156 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9157
9158 $style_settings = array(
9159 'ajax_url' => admin_url('admin-ajax.php'),
9160 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9161 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9162 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9163 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9164 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9165 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9166 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9167 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9168 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9169 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9170 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9171 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9172 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9173 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9174 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9175 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9176 'icon_color' => $this->options['icon_color'] ?? '#fff',
9177 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9178 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9179 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9180 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9181 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9182 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9183 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9184 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9185 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9186 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9187 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
9188 'initial_email_state' => null,
9189 'skip_email_check' => true,
9190 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9191 'skip_inline_colors' => $skip_inline_colors,
9192 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9193 );
9194
9195 // Determine delay time based on strategy
9196 $delay_ms = 0;
9197 switch ($loading_strategy) {
9198 case 'delay_1s':
9199 $delay_ms = 1000;
9200 break;
9201 case 'delay_3s':
9202 $delay_ms = 3000;
9203 break;
9204 case 'delay_5s':
9205 $delay_ms = 5000;
9206 break;
9207 }
9208
9209 ?>
9210 <script type="text/javascript">
9211 (function() {
9212 var mxchatLoaded = false;
9213 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
9214 window.mxchatChat = mxchatChat;
9215
9216 function loadMxChatScript() {
9217 if (mxchatLoaded) return;
9218 mxchatLoaded = true;
9219
9220 var script = document.createElement('script');
9221 script.src = <?php echo wp_json_encode($script_url); ?>;
9222 script.type = 'text/javascript';
9223 document.body.appendChild(script);
9224 }
9225
9226 <?php if ($loading_strategy === 'on_interaction'): ?>
9227 // Load on user interaction
9228 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
9229 events.forEach(function(evt) {
9230 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
9231 });
9232 // Fallback: load after 8 seconds if no interaction
9233 setTimeout(loadMxChatScript, 8000);
9234 <?php else: ?>
9235 // Load after specified delay
9236 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
9237 <?php endif; ?>
9238 })();
9239 </script>
9240 <?php
9241 }
9242
9243 /**
9244 * Setup the cron jobs for rate limits with guard against multiple calls
9245 */
9246 public function setup_rate_limit_cron_jobs() {
9247 // Add a guard to prevent multiple rapid calls
9248 $last_setup = get_transient('mxchat_cron_setup_guard');
9249 if ($last_setup && (time() - $last_setup) < 60) {
9250 // Don't run again if we ran less than 60 seconds ago
9251 return;
9252 }
9253
9254 // Set the guard
9255 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
9256
9257 try {
9258 // First, check if WordPress cron is disabled
9259 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
9260 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
9261 $this->setup_fallback_rate_limit_system();
9262 return;
9263 }
9264
9265 // Check if cron is already scheduled - if so, don't mess with it
9266 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
9267 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
9268 return;
9269 }
9270
9271 // Clear any orphaned hooks (but don't loop indefinitely)
9272 $hooks_to_clear = [
9273 'mxchat_reset_rate_limits',
9274 'mxchat_reset_hourly_rate_limits',
9275 'mxchat_reset_daily_rate_limits',
9276 'mxchat_reset_weekly_rate_limits',
9277 'mxchat_reset_monthly_rate_limits'
9278 ];
9279
9280 foreach ($hooks_to_clear as $hook) {
9281 // Only clear a maximum of 3 instances to prevent infinite loops
9282 $cleared = 0;
9283 while (wp_next_scheduled($hook) && $cleared < 3) {
9284 wp_clear_scheduled_hook($hook);
9285 $cleared++;
9286 }
9287 }
9288
9289 // Small delay after clearing
9290 usleep(100000); // 0.1 seconds
9291
9292 // Try to schedule the event
9293 $initial_time = time() + 300; // Start in 5 minutes
9294 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
9295
9296 if ($result === false) {
9297 //error_log('MxChat: Failed to schedule cron, using fallback system');
9298 $this->setup_fallback_rate_limit_system();
9299 } else {
9300 //error_log('MxChat: Successfully scheduled rate limit reset cron');
9301 }
9302
9303 } catch (Exception $e) {
9304 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
9305 $this->setup_fallback_rate_limit_system();
9306 }
9307 }
9308
9309 /**
9310 * Try alternative cron scheduling methods
9311 */
9312 private function try_alternative_cron_scheduling($initial_time) {
9313 try {
9314 // Method 1: Try with current time instead of future time
9315 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
9316 if ($result1 !== false) {
9317 //error_log('MxChat: Alternative method 1 (current time) succeeded');
9318 return true;
9319 }
9320
9321 // Method 2: Try with a different interval
9322 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
9323 if ($result2 !== false) {
9324 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
9325 return true;
9326 }
9327
9328 // Method 3: Try wp_schedule_single_event first, then recurring
9329 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
9330 if ($result3 !== false) {
9331 //error_log('MxChat: Alternative method 3 (single event) succeeded');
9332 // Schedule the next one manually in the handler
9333 return true;
9334 }
9335
9336 return false;
9337
9338 } catch (Exception $e) {
9339 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
9340 return false;
9341 }
9342 }
9343
9344 /**
9345 * Enhanced fallback rate limit system
9346 */
9347 private function setup_fallback_rate_limit_system() {
9348 // Set a flag to use database-based rate limit cleanup
9349 update_option('mxchat_use_fallback_rate_limits', true);
9350
9351 // Schedule a one-time check to happen on the next plugin load
9352 update_option('mxchat_next_rate_limit_check', time() + 3600);
9353
9354 // Also set up a more frequent fallback check (every 4 hours)
9355 update_option('mxchat_fallback_check_interval', 4 * 3600);
9356
9357 //error_log('MxChat: Fallback rate limit system activated');
9358 }
9359
9360 /**
9361 * Enhanced fallback check method
9362 */
9363 public function check_fallback_rate_limits() {
9364 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9365
9366 if (!$use_fallback) {
9367 return; // Regular cron is working
9368 }
9369
9370 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9371 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
9372
9373 if (time() >= $next_check) {
9374 //error_log('MxChat: Running fallback rate limit cleanup');
9375 $this->mxchat_reset_rate_limits();
9376
9377 // Schedule next check
9378 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
9379 }
9380 }
9381 /**
9382 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
9383 */
9384 public function check_rate_limit() {
9385 // Check if we need to run fallback cleanup
9386 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9387 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9388
9389 if ($use_fallback && time() >= $next_check) {
9390 $this->mxchat_reset_rate_limits();
9391 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
9392 }
9393
9394 // Get bot ID from current request context
9395 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9396
9397 // Get bot-specific options (includes rate limits if overridden)
9398 $bot_options = $this->get_bot_options($bot_id);
9399 $current_options = !empty($bot_options) ? $bot_options : $this->options;
9400
9401 // Use bot-specific rate limits if available, otherwise fall back to default
9402 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
9403
9404 // Determine user role or if logged out
9405 if (is_user_logged_in()) {
9406 $user = wp_get_current_user();
9407 $user_id = $user->ID;
9408
9409 // Get the user's primary role using reset() to safely get the first element
9410 $user_roles = $user->roles;
9411
9412 // Safely get the first role regardless of array key structure
9413 if (!empty($user_roles) && is_array($user_roles)) {
9414 $role = reset($user_roles); // This safely gets the first element regardless of key
9415 } else {
9416 $role = 'subscriber'; // Default to subscriber if no role found
9417 }
9418 } else {
9419 $role = 'logged_out';
9420 // Use IP address for non-logged-in users
9421 $user_id = $this->get_client_ip();
9422 }
9423
9424 // Check if rate limits are configured for this role
9425 if (!isset($rate_limits_source[$role])) {
9426 return true; // No limit set for this role
9427 }
9428
9429 $limit = $rate_limits_source[$role]['limit'];
9430
9431 // If unlimited, return true immediately
9432 if ($limit === 'unlimited') {
9433 return true;
9434 }
9435
9436 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
9437 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
9438 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
9439 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
9440
9441 // Include bot_id in option name so each bot has separate rate limits
9442 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
9443
9444 // Get the counter data
9445 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
9446
9447 // If first request or counter reset needed, set the initial timestamp
9448 if ($limit_data['count'] === 0) {
9449 $limit_data['timestamp'] = time();
9450 update_option($option_name, $limit_data);
9451 }
9452
9453 // Get the timeframe
9454 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
9455 $rate_limits_source[$role]['timeframe'] : 'daily';
9456
9457 // Check if the counter needs to be reset based on timeframe
9458 $current_time = time();
9459 $timestamp = $limit_data['timestamp'];
9460 $should_reset = false;
9461
9462 switch ($timeframe) {
9463 case 'hourly':
9464 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
9465 break;
9466 case 'daily':
9467 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
9468 break;
9469 case 'weekly':
9470 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
9471 break;
9472 case 'monthly':
9473 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
9474 break;
9475 }
9476
9477 // Reset the counter if the timeframe has passed
9478 if ($should_reset) {
9479 $limit_data = ['count' => 0, 'timestamp' => $current_time];
9480 update_option($option_name, $limit_data);
9481 }
9482
9483 // Check if user has exceeded their limit
9484 if ($limit_data['count'] >= intval($limit)) {
9485 // Get the custom message for this role
9486 $message = !empty($rate_limits_source[$role]['message'])
9487 ? $rate_limits_source[$role]['message']
9488 : __('Rate limit exceeded. Please try again later.', 'mxchat');
9489
9490 // Add timeframe information to the message if placeholders exist
9491 $timeframe_label = '';
9492 switch ($timeframe) {
9493 case 'hourly':
9494 $timeframe_label = __('hour', 'mxchat');
9495 break;
9496 case 'daily':
9497 $timeframe_label = __('day', 'mxchat');
9498 break;
9499 case 'weekly':
9500 $timeframe_label = __('week', 'mxchat');
9501 break;
9502 case 'monthly':
9503 $timeframe_label = __('month', 'mxchat');
9504 break;
9505 }
9506
9507 // Replace placeholders in the message
9508 $message = str_replace(
9509 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
9510 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
9511 $message
9512 );
9513
9514 // Process HTML links in the message
9515 $message = $this->process_rate_limit_message_html($message);
9516
9517 // Return error with the processed message
9518 return [
9519 'error' => true,
9520 'message' => $message
9521 ];
9522 }
9523
9524 // Increment the counter
9525 $limit_data['count']++;
9526 update_option($option_name, $limit_data);
9527
9528 return true;
9529 }
9530
9531 /**
9532 * Enhanced rate limit reset with better error handling
9533 */
9534 public function mxchat_reset_rate_limits() {
9535 try {
9536 global $wpdb;
9537 $all_options = get_option('mxchat_options', []);
9538 $current_time = time();
9539
9540 // Get rate limit options with a safer query and limit
9541 $option_names = $wpdb->get_col(
9542 $wpdb->prepare(
9543 "SELECT option_name FROM {$wpdb->options}
9544 WHERE option_name LIKE %s
9545 LIMIT 1000",
9546 'mxchat_chat_limit_%'
9547 )
9548 );
9549
9550 if (empty($option_names)) {
9551 return;
9552 }
9553
9554 $processed_count = 0;
9555 $max_processing_time = 30; // Maximum 30 seconds
9556 $start_time = time();
9557
9558 foreach ($option_names as $option_name) {
9559 // Check processing time limit
9560 if ((time() - $start_time) > $max_processing_time) {
9561 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
9562 break;
9563 }
9564
9565 // Parse the option name more safely
9566 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
9567 continue;
9568 }
9569
9570 $role_and_user = $matches[1] . '_' . $matches[2];
9571 $parts = explode('_', $role_and_user);
9572
9573 if (count($parts) < 2) {
9574 continue;
9575 }
9576
9577 // Extract role (everything except the last part which is user ID)
9578 $user_id_part = array_pop($parts);
9579 $role = implode('_', $parts);
9580
9581 // Skip if role doesn't exist in our settings
9582 if (!isset($all_options['rate_limits'][$role])) {
9583 // Clean up orphaned entries
9584 delete_option($option_name);
9585 continue;
9586 }
9587
9588 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
9589 $limit_data = get_option($option_name);
9590
9591 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
9592 // Clean up invalid entries
9593 delete_option($option_name);
9594 continue;
9595 }
9596
9597 $timestamp = $limit_data['timestamp'];
9598 $should_reset = false;
9599
9600 // Determine if we should reset based on the timeframe
9601 switch ($timeframe) {
9602 case 'hourly':
9603 $should_reset = ($current_time - $timestamp) >= 3600;
9604 break;
9605 case 'daily':
9606 $should_reset = ($current_time - $timestamp) >= 86400;
9607 break;
9608 case 'weekly':
9609 $should_reset = ($current_time - $timestamp) >= 604800;
9610 break;
9611 case 'monthly':
9612 $should_reset = ($current_time - $timestamp) >= 2592000;
9613 break;
9614 }
9615
9616 // Reset the counter if the timeframe has passed
9617 if ($should_reset) {
9618 delete_option($option_name);
9619 wp_cache_delete($option_name, 'options');
9620 $processed_count++;
9621 }
9622 }
9623
9624 // Clean up any orphaned cache entries
9625 wp_cache_delete('mxchat_all_chat_limits', 'options');
9626
9627 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
9628
9629 } catch (Exception $e) {
9630 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
9631 }
9632 }
9633
9634
9635 /**
9636 * Process HTML links in rate limit messages
9637 *
9638 * @param string $message The rate limit message
9639 * @return string The processed message with safe HTML links
9640 */
9641 private function process_rate_limit_message_html($message) {
9642 // Return original message if empty
9643 if (empty($message)) {
9644 return $message;
9645 }
9646
9647 // First, convert markdown links to HTML
9648 $message = $this->convert_markdown_links($message);
9649
9650 // Then, auto-convert any remaining plain URLs to links
9651 $message = $this->auto_link_urls($message);
9652
9653 // Allow basic HTML tags for links and formatting
9654 $allowed_tags = [
9655 'a' => [
9656 'href' => true,
9657 'target' => true,
9658 'rel' => true,
9659 'title' => true,
9660 'class' => true
9661 ],
9662 'strong' => [],
9663 'em' => [],
9664 'br' => [],
9665 'b' => [],
9666 'i' => [],
9667 'span' => ['class' => true]
9668 ];
9669
9670 // Sanitize but allow the specified HTML tags
9671 $processed_message = wp_kses($message, $allowed_tags);
9672
9673 // If wp_kses stripped everything, return the original message as plain text
9674 if (empty($processed_message) && !empty($message)) {
9675 // Strip all HTML and return plain text as fallback
9676 return wp_strip_all_tags($message);
9677 }
9678
9679 return $processed_message;
9680 }
9681
9682 /**
9683 * Convert markdown links to HTML
9684 *
9685 * @param string $text The text to process
9686 * @return string The text with markdown links converted to HTML
9687 */
9688 private function convert_markdown_links($text) {
9689 // Return original text if empty
9690 if (empty($text)) {
9691 return $text;
9692 }
9693
9694 // Pattern to match markdown links: [text](url)
9695 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
9696
9697 $processed_text = preg_replace_callback($pattern, function($matches) {
9698 $link_text = $matches[1];
9699 $url = $matches[2];
9700
9701 // Clean up any trailing punctuation from the URL
9702 $url = rtrim($url, '.,;:!?');
9703
9704 // Sanitize the link text and URL
9705 $safe_text = esc_html($link_text);
9706 $safe_url = esc_url($url);
9707
9708 // Create the HTML link
9709 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
9710 }, $text);
9711
9712 // If preg_replace_callback failed, return original text
9713 if ($processed_text === null) {
9714 return $text;
9715 }
9716
9717 return $processed_text;
9718 }
9719
9720 /**
9721 * Auto-convert plain URLs to clickable links
9722 *
9723 * @param string $text The text to process
9724 * @return string The text with URLs converted to links
9725 */
9726 private function auto_link_urls($text) {
9727 // Return original text if empty
9728 if (empty($text)) {
9729 return $text;
9730 }
9731
9732 // Simple pattern that avoids complex lookbehinds
9733 // This will match URLs that are not already inside href attributes or markdown links
9734 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
9735
9736 $processed_text = preg_replace_callback($pattern, function($matches) {
9737 $url = $matches[0];
9738 // Clean up any trailing punctuation that might have been captured
9739 $url = rtrim($url, '.,;:!?');
9740
9741 // Add target="_blank" and rel="noopener noreferrer" for security
9742 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
9743 }, $text);
9744
9745 // If preg_replace_callback failed, return original text
9746 if ($processed_text === null) {
9747 return $text;
9748 }
9749
9750 return $processed_text;
9751 }
9752
9753
9754 // Helper function to get client IP address
9755 private function get_client_ip() {
9756 // Check for shared internet/ISP IP
9757 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
9758 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
9759 }
9760
9761 // Check for IPs passing through proxies
9762 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
9763 // Use the first value in the comma-separated list
9764 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
9765 return trim($forwarded_for[0]);
9766 }
9767
9768 if (!empty($_SERVER['REMOTE_ADDR'])) {
9769 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
9770 }
9771
9772 // Fallback
9773 return 'unknown';
9774 }
9775
9776 /**
9777 * AJAX handler to get system information for testing panel
9778 */
9779 /**
9780 * AJAX handler to get system information for testing panel
9781 */
9782 public function mxchat_get_system_info() {
9783 // Verify nonce for security
9784 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9785 wp_send_json_error(['message' => 'Invalid nonce']);
9786 return;
9787 }
9788
9789 // Only allow admin users
9790 if (!current_user_can('administrator')) {
9791 wp_send_json_error(['message' => 'Unauthorized']);
9792 return;
9793 }
9794
9795 // Get system prompt from options
9796 $system_prompt = isset($this->options['system_prompt_instructions'])
9797 ? $this->options['system_prompt_instructions']
9798 : 'No system prompt configured';
9799
9800 // Get selected model
9801 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
9802
9803 // Check if OpenRouter is being used
9804 $is_openrouter = ($selected_model === 'openrouter');
9805 $openrouter_model = '';
9806
9807 if ($is_openrouter) {
9808 // Get the actual OpenRouter model that's selected
9809 $openrouter_model = isset($this->options['openrouter_selected_model'])
9810 ? $this->options['openrouter_selected_model']
9811 : 'No OpenRouter model selected';
9812
9813 // Update selected_model display to show both
9814 $selected_model = 'OpenRouter: ' . $openrouter_model;
9815 }
9816
9817 // Get API key status (just check if they exist, don't expose the keys)
9818 $api_status = [];
9819 $api_status['openai'] = !empty($this->options['api_key']);
9820 $api_status['claude'] = !empty($this->options['claude_api_key']);
9821 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
9822 $api_status['xai'] = !empty($this->options['xai_api_key']);
9823 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
9824 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
9825
9826 wp_send_json_success([
9827 'system_prompt' => $system_prompt,
9828 'selected_model' => $selected_model,
9829 'is_openrouter' => $is_openrouter,
9830 'openrouter_model' => $openrouter_model,
9831 'api_status' => $api_status
9832 ]);
9833 }
9834
9835 /**
9836 * AJAX handler to get similarity threshold
9837 */
9838 public function mxchat_get_similarity_threshold() {
9839 // Verify nonce for security
9840 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9841 wp_send_json_error(['message' => 'Invalid nonce']);
9842 return;
9843 }
9844
9845 // Only allow admin users
9846 if (!current_user_can('administrator')) {
9847 wp_send_json_error(['message' => 'Unauthorized']);
9848 return;
9849 }
9850
9851 // Get similarity threshold from main options (default 35%)
9852 $similarity_threshold = isset($this->options['similarity_threshold'])
9853 ? ((int) $this->options['similarity_threshold']) / 100
9854 : 0.35;
9855
9856 wp_send_json_success([
9857 'threshold' => $similarity_threshold,
9858 'threshold_percentage' => ($similarity_threshold * 100) . '%'
9859 ]);
9860 }
9861
9862 /**
9863 * AJAX handler to get knowledge base status
9864 */
9865 public function mxchat_get_kb_status() {
9866 // Verify nonce for security
9867 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9868 wp_send_json_error(['message' => 'Invalid nonce']);
9869 return;
9870 }
9871
9872 // Only allow admin users
9873 if (!current_user_can('administrator')) {
9874 wp_send_json_error(['message' => 'Unauthorized']);
9875 return;
9876 }
9877
9878 // Check OpenAI Vector Store first (takes priority)
9879 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
9880 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
9881
9882 if ($use_vectorstore) {
9883 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
9884 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
9885
9886 $kb_info = [
9887 'type' => 'OpenAI Vector Store',
9888 'status' => 'Active',
9889 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
9890 ];
9891
9892 wp_send_json_success($kb_info);
9893 return;
9894 }
9895
9896 // Check Pinecone vs WordPress
9897 $addon_options = get_option('mxchat_pinecone_addon_options', array());
9898 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
9899
9900 $kb_info = [
9901 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
9902 'status' => 'Active'
9903 ];
9904
9905 // Get document count
9906 if ($use_pinecone) {
9907 $kb_info['documents'] = 'Connected to Pinecone';
9908 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
9909 } else {
9910 // Count documents in WordPress database
9911 global $wpdb;
9912 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9913 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
9914 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
9915 }
9916
9917 wp_send_json_success($kb_info);
9918 }
9919
9920 /**
9921 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
9922 */
9923 public function mxchat_start_fresh_session() {
9924 // Verify nonce for security
9925 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9926 wp_send_json_error(['message' => 'Invalid nonce']);
9927 return;
9928 }
9929
9930 // Only allow admin users
9931 if (!current_user_can('administrator')) {
9932 wp_send_json_error(['message' => 'Unauthorized']);
9933 return;
9934 }
9935
9936 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
9937 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
9938
9939 if (empty($old_session_id)) {
9940 wp_send_json_error(['message' => 'Old session ID required']);
9941 return;
9942 }
9943
9944 // If no new session ID provided, generate one
9945 if (empty($new_session_id)) {
9946 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
9947 }
9948
9949 // Clear ALL data associated with the old session
9950 $this->clear_complete_session_data($old_session_id);
9951
9952 // Initialize the new session
9953 $this->initialize_fresh_session($new_session_id);
9954
9955 wp_send_json_success([
9956 'message' => 'Fresh session started successfully',
9957 'new_session_id' => $new_session_id,
9958 'old_session_id' => $old_session_id
9959 ]);
9960 }
9961
9962 /**
9963 * Clear ALL data associated with a session (ENHANCED)
9964 */
9965 private function clear_complete_session_data($session_id) {
9966 // Clear chat history
9967 delete_option("mxchat_history_{$session_id}");
9968
9969 // Clear chat mode
9970 delete_option("mxchat_mode_{$session_id}");
9971
9972 // Clear any PDF/Word transients
9973 $this->clear_pdf_transients($session_id);
9974 if (method_exists($this, 'clear_word_transients')) {
9975 $this->clear_word_transients($session_id);
9976 }
9977
9978 // Clear agent-related data
9979 delete_option("mxchat_channel_{$session_id}");
9980 delete_option("mxchat_agent_name_{$session_id}");
9981 delete_option("mxchat_email_{$session_id}");
9982
9983 // Clear any recommendation flow state
9984 delete_option("mxchat_sr_flow_state_{$session_id}");
9985
9986 // Clear any cached embeddings or context
9987 delete_transient("mxchat_context_{$session_id}");
9988 delete_transient("mxchat_last_query_{$session_id}");
9989
9990 // Clear any testing data
9991 delete_transient("mxchat_testing_data_{$session_id}");
9992
9993 // Clear any rate limiting data for this session
9994 delete_transient("mxchat_rate_limit_{$session_id}");
9995
9996 // Clear any other session-specific transients
9997 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
9998 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
9999 delete_transient("mxchat_include_word_in_context_{$session_id}");
10000
10001 // Clear form addon state (pending forms and submitted forms)
10002 delete_option("mxchat_pending_form_{$session_id}");
10003 delete_option("mxchat_submitted_forms_{$session_id}");
10004
10005 //error_log("MxChat: Cleared all data for session: {$session_id}");
10006 }
10007
10008 /**
10009 * Initialize a fresh session with default data
10010 */
10011 private function initialize_fresh_session($session_id) {
10012 // Set default chat mode
10013 update_option("mxchat_mode_{$session_id}", 'ai');
10014
10015 //error_log("MxChat: Initialized fresh session: {$session_id}");
10016 }
10017
10018 /**
10019 * Helper method to clear Word document transients (if you have Word support)
10020 */
10021 private function clear_word_transients($session_id) {
10022 delete_transient('mxchat_word_url_' . $session_id);
10023 delete_transient('mxchat_word_filename_' . $session_id);
10024 delete_transient('mxchat_word_embeddings_' . $session_id);
10025 delete_transient('mxchat_include_word_in_context_' . $session_id);
10026 }
10027
10028 /**
10029 * Simplified testing data capture method (CLEANED UP)
10030 */
10031 private function capture_testing_data($user_embedding, $message, $session_id) {
10032 // Only capture for admin users
10033 if (!current_user_can('administrator')) {
10034 return null;
10035 }
10036
10037 $testing_data = [
10038 'query' => $message,
10039 'timestamp' => time(),
10040 'top_matches' => [],
10041 'action_matches' => [] // Add action matches
10042 ];
10043
10044 // Get similarity threshold
10045 $similarity_threshold = isset($this->options['similarity_threshold'])
10046 ? ((int) $this->options['similarity_threshold']) / 100
10047 : 0.35;
10048
10049 $testing_data['similarity_threshold'] = $similarity_threshold;
10050
10051 // Use the real similarity analysis if available
10052 if ($this->last_similarity_analysis !== null) {
10053 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
10054 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
10055 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10056 } else {
10057 // Fallback: determine knowledge base type
10058 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10059 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10060
10061 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
10062 }
10063
10064 // Include action analysis if available
10065 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
10066 $testing_data['action_matches'] = $this->last_action_analysis;
10067
10068 // Clear it after capturing to avoid stale data
10069 $this->last_action_analysis = null;
10070 }
10071
10072 return $testing_data;
10073 }
10074
10075
10076 /**
10077 * Track URL clicks from chatbot responses
10078 */
10079 public function mxchat_track_url_click() {
10080 // Verify nonce for security
10081 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10082 wp_send_json_error(['message' => 'Invalid nonce']);
10083 wp_die();
10084 }
10085
10086 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10087 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
10088 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
10089
10090 if (empty($session_id) || empty($clicked_url)) {
10091 wp_send_json_error(['message' => 'Missing required data']);
10092 wp_die();
10093 }
10094
10095 global $wpdb;
10096 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10097
10098 // Insert click tracking record
10099 $wpdb->insert(
10100 $table_name,
10101 [
10102 'session_id' => $session_id,
10103 'clicked_url' => $clicked_url,
10104 'message_context' => $message_context,
10105 'click_timestamp' => current_time('mysql', 1),
10106 'user_ip' => $_SERVER['REMOTE_ADDR'],
10107 'user_agent' => $_SERVER['HTTP_USER_AGENT']
10108 ]
10109 );
10110
10111 wp_send_json_success(['message' => 'Click tracked']);
10112 wp_die();
10113 }
10114
10115 /**
10116 * Get URL click analytics for a session
10117 */
10118 public function mxchat_get_url_clicks($session_id) {
10119 global $wpdb;
10120 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10121
10122 $clicks = $wpdb->get_results($wpdb->prepare(
10123 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
10124 $session_id
10125 ));
10126
10127 return $clicks;
10128 }
10129 /**
10130 * Track the originating page where chat was started
10131 */
10132 public function mxchat_track_originating_page() {
10133 // Verify nonce
10134 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10135 wp_send_json_error(['message' => 'Invalid nonce']);
10136 wp_die();
10137 }
10138
10139 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10140 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
10141 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
10142
10143 if (empty($session_id)) {
10144 wp_send_json_error(['message' => 'Missing session ID']);
10145 wp_die();
10146 }
10147
10148 global $wpdb;
10149 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
10150
10151 // Check if we've already tracked for this session
10152 $existing = $wpdb->get_var($wpdb->prepare(
10153 "SELECT COUNT(*) FROM $table_name
10154 WHERE session_id = %s
10155 AND originating_page_url IS NOT NULL",
10156 $session_id
10157 ));
10158
10159 if ($existing > 0) {
10160 wp_send_json_success(['message' => 'Already tracked']);
10161 wp_die();
10162 }
10163
10164 // Update the first message in this session with originating page info
10165 $wpdb->query($wpdb->prepare(
10166 "UPDATE $table_name
10167 SET originating_page_url = %s,
10168 originating_page_title = %s
10169 WHERE session_id = %s
10170 ORDER BY timestamp ASC
10171 LIMIT 1",
10172 $page_url,
10173 $page_title,
10174 $session_id
10175 ));
10176
10177 wp_send_json_success(['message' => 'Originating page tracked']);
10178 wp_die();
10179 }
10180
10181 /**
10182 * Validate and clean URLs from AI response
10183 * Removes any URLs that aren't in the knowledge base
10184 *
10185 * @param string $response_text The AI-generated response
10186 * @param array $valid_urls Array of URLs from the knowledge base
10187 * @return string Cleaned response with invalid URLs removed/flagged
10188 */
10189 private function validate_and_clean_urls($response_text, $valid_urls) {
10190 // DEBUG: Log what we're working with
10191 error_log("=== MxChat URL Validation Debug ===");
10192 error_log("Valid URLs count: " . count($valid_urls));
10193 error_log("Valid URLs: " . print_r($valid_urls, true));
10194 error_log("Response text length: " . strlen($response_text));
10195 error_log("Response text preview: " . substr($response_text, 0, 500));
10196
10197 // If no valid URLs provided or empty response, return as-is
10198 if (empty($valid_urls) || empty($response_text)) {
10199 error_log("Validation skipped - empty valid_urls or response");
10200 return $response_text;
10201 }
10202
10203 // Extract all URLs from the AI response
10204 // This regex matches http:// and https:// URLs
10205 preg_match_all(
10206 '#\bhttps?://[^\s<>"\')\]]+#i',
10207 $response_text,
10208 $matches
10209 );
10210
10211 // If no URLs found in response, return as-is
10212 if (empty($matches[0])) {
10213 error_log("No URLs found in response");
10214 return $response_text;
10215 }
10216
10217 $found_urls = $matches[0];
10218 $cleaned_response = $response_text;
10219 $removed_count = 0;
10220
10221 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
10222 $normalized_valid_urls = array_map(function($url) {
10223 // Remove trailing slash
10224 $url = rtrim($url, '/');
10225 // Remove URL fragments (#section)
10226 $url = preg_replace('/#.*$/', '', $url);
10227 // Remove trailing punctuation that might have been captured
10228 $url = rtrim($url, '.,;:!?');
10229 return $url;
10230 }, $valid_urls);
10231
10232 error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
10233
10234 foreach ($found_urls as $found_url) {
10235 // Clean up the found URL (remove trailing punctuation that might have been captured)
10236 $clean_found_url = rtrim($found_url, '.,;:!?)');
10237
10238 // DEBUG: Log each URL being checked
10239 error_log("Checking found URL: " . $found_url);
10240
10241 // Normalize for comparison
10242 $normalized_found = rtrim($clean_found_url, '/');
10243 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
10244
10245 error_log("Normalized found URL: " . $normalized_found);
10246
10247 // Check if this URL exists in our valid URLs list
10248 $is_valid = false;
10249
10250 error_log("Starting validation checks for: " . $normalized_found);
10251
10252 // First, try exact match
10253 if (in_array($normalized_found, $normalized_valid_urls)) {
10254 $is_valid = true;
10255 error_log("EXACT MATCH FOUND");
10256 } else {
10257 error_log("No exact match, checking variations...");
10258 // If no exact match, check if it's a variation (with query params, etc.)
10259 foreach ($normalized_valid_urls as $valid_url) {
10260 error_log(" Comparing against valid URL: " . $valid_url);
10261
10262 // Check if the found URL starts with a valid URL (handles query params)
10263 if (strpos($normalized_found, $valid_url) === 0) {
10264 // Check what comes after the valid URL
10265 $remainder = substr($normalized_found, strlen($valid_url));
10266
10267 // Only valid if:
10268 // 1. Exact match (remainder is empty)
10269 // 2. Query params (starts with ?)
10270 // 3. Fragment (starts with #)
10271 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
10272 $is_valid = true;
10273 error_log(" MATCH: Found URL is valid variation of base URL");
10274 break;
10275 } else {
10276 error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
10277 }
10278 }
10279 // Also check the reverse (in case valid URL has query params)
10280 if (strpos($valid_url, $normalized_found) === 0) {
10281 $is_valid = true;
10282 error_log(" MATCH: Valid URL starts with found URL");
10283 break;
10284 }
10285 }
10286
10287 if (!$is_valid) {
10288 error_log("NO MATCH FOUND - URL should be removed");
10289 }
10290 }
10291
10292 // If URL is not valid, remove it from the response
10293 if (!$is_valid) {
10294 // Log the removal for debugging
10295 error_log("MxChat: Removed hallucinated URL: " . $found_url);
10296 error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
10297
10298 $removed_count++;
10299
10300 // Check if URL is part of a markdown link: [text](url)
10301 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
10302 if (preg_match($markdown_pattern, $cleaned_response)) {
10303 error_log("Found markdown link, removing but keeping text");
10304 // Remove the markdown link but keep the text
10305 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
10306 }
10307 // Check if URL is part of an HTML link: <a href="url">text</a>
10308 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
10309 error_log("Found HTML link, removing but keeping text");
10310 // Remove the HTML link but keep the text
10311 $link_text = $link_match[1];
10312 $cleaned_response = preg_replace(
10313 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
10314 $link_text,
10315 $cleaned_response
10316 );
10317 }
10318 // Otherwise just remove the bare URL
10319 else {
10320 error_log("Removing bare URL");
10321 $cleaned_response = str_replace($found_url, '', $cleaned_response);
10322 }
10323 }
10324 }
10325
10326 // Log summary if any URLs were removed
10327 if ($removed_count > 0) {
10328 error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
10329 } else {
10330 error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
10331 }
10332
10333 // Clean up any double spaces or awkward punctuation left behind
10334 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
10335 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
10336 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
10337
10338 error_log("Final cleaned response: " . $cleaned_response);
10339
10340 return trim($cleaned_response);
10341 }
10342
10343 /**
10344 * AJAX handler to get current chat mode for a session
10345 */
10346 public function mxchat_get_current_chat_mode() {
10347 // Verify nonce for security
10348 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10349 wp_send_json_error(['message' => 'Invalid nonce']);
10350 wp_die();
10351 }
10352
10353 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10354
10355 if (empty($session_id)) {
10356 wp_send_json_error(['message' => 'Session ID missing']);
10357 wp_die();
10358 }
10359
10360 // Get the current chat mode for this session
10361 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
10362
10363 wp_send_json_success([
10364 'chat_mode' => $chat_mode
10365 ]);
10366 wp_die();
10367 }
10368
10369
10370
10371 }
10372 ?>
10373