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

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