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

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

10,675 lines 425.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 class MxChat_Integrator {
7 private $options;
8 private $prompts_options;
9 private $chat_count;
10 private $fallbackResponse;
11 private $productCardHtml;
12 private $word_handler;
13 private $last_similarity_analysis = null;
14 private $current_valid_urls = [];
15 private $last_vectorstore_error = null;
16 private $is_streaming = false; // ADDED: Track if current request is streaming
17 private $streaming_headers_sent = false; // Track if streaming headers have been sent
18
19 /**
20 * Setup streaming headers - call this right before actually streaming
21 * This delays header setup to allow actions/forms to return JSON responses
22 */
23 private function setup_streaming_headers() {
24 if ($this->streaming_headers_sent || headers_sent()) {
25 return false;
26 }
27
28 // Disable output buffering
29 while (ob_get_level()) {
30 ob_end_flush();
31 }
32
33 // Set headers for SSE
34 header('Content-Type: text/event-stream');
35 header('Cache-Control: no-cache');
36 header('Connection: keep-alive');
37 header('X-Accel-Buffering: no');
38
39 ob_implicit_flush(true);
40 flush();
41
42 $this->streaming_headers_sent = true;
43 return true;
44 }
45
46 /**
47 * Class constructor
48 */
49 public function __construct() {
50 $this->options = get_option('mxchat_options');
51 $this->prompts_options = get_option('mxchat_prompts_options', array());
52 $this->chat_count = get_option('mxchat_chat_count', 0);
53 $this->word_handler = new MXChat_Word_Handler($this->options);
54
55 // Add all action hooks
56 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
57 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
58 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
59 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
60 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
61
62 // Add the AJAX actions for checking if the pre-chat message was dismissed
63 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
64 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
65 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
66 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
67 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
68 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
69
70 // Add REST API routes registration
71 add_action('rest_api_init', array($this, 'register_routes'));
72 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
73 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
74
75 // Rate limit action - notice we removed the old schedule setup
76 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
77
78 // File upload and handling actions
79 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
80 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
81 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
82 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
83
84 // Word document handling actions
85 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
86 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
87 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
88 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
89 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
90 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
91
92 // Email handling actions
93 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
94 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
95 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
96 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
97
98 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
99 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
100
101 // Testing panel AJAX actions
102 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
103 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
104 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
105 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
106 // Add to your existing constructor, in the section with other AJAX actions:
107 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
108 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
109 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
110 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
111 // Add chat mode checking actions
112 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
113 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
114
115 // Nonce refresh for page-cache compatibility (WP Rocket, LiteSpeed, etc.)
116 add_action('wp_ajax_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
117 add_action('wp_ajax_nopriv_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
118
119 // Auto-email transcript action
120 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
121
122 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
123
124
125 }
126
127 /**
128 * Return a fresh nonce so cached pages can replace the stale one.
129 */
130 public function mxchat_refresh_nonce() {
131 nocache_headers();
132 wp_send_json_success(array('nonce' => wp_create_nonce('mxchat_chat_nonce')));
133 }
134
135 // In your core plugin's check_actions_for_addons method:
136 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
137 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
138
139 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
140
141 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
142
143 return $result;
144 }
145
146 private function mxchat_increment_chat_count() {
147 $chat_count = get_option('mxchat_chat_count', 0);
148 $chat_count++;
149 update_option('mxchat_chat_count', $chat_count);
150 }
151
152 function mxchat_fetch_conversation_history() {
153 if (empty($_POST['session_id'])) {
154 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
155 wp_die();
156 }
157
158 $session_id = sanitize_text_field($_POST['session_id']);
159
160 // SECURITY FIX: Verify session ownership before retrieving data
161 // If IP/user changed, signal frontend to reset session instead of blocking
162 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
163
164 // Check if this session has an owner recorded
165 $session_owner = get_option("mxchat_session_owner_{$session_id}");
166
167 // If session has an owner and it doesn't match current user, trigger session reset
168 if ($session_owner && $session_owner !== $current_user_identifier) {
169 wp_send_json_error([
170 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
171 'code' => 'session_expired',
172 'action' => 'reset_session'
173 ]);
174 wp_die();
175 }
176
177 // If no owner is set yet, claim ownership (for legacy sessions)
178 if (!$session_owner) {
179 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
180 }
181
182 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
183 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
184
185 if (empty($history)) {
186 // Even if history is empty, return the chat mode
187 wp_send_json_success([
188 'conversation' => [],
189 'chat_mode' => $chat_mode
190 ]);
191 wp_die();
192 }
193
194 wp_send_json_success([
195 'conversation' => $history,
196 'chat_mode' => $chat_mode
197 ]);
198 wp_die();
199 }
200 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
201 $history = get_option("mxchat_history_{$session_id}", []);
202
203 // Check persistence setting - when OFF, only include messages from current page load
204 $options = get_option('mxchat_options', []);
205 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
206
207 // Filter history when persistence is OFF to match what the user sees
208 if (!$persistence_enabled && $session_start_timestamp > 0) {
209 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
210 // Include messages from this page load onwards
211 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
212 });
213 // Re-index array after filtering
214 $history = array_values($history);
215 }
216
217 $formatted_history = [];
218
219 // Adjusted for code-heavy conversations
220 $max_tokens = 120000; // Context window size
221 $reserved_tokens = 5000; // Space for system prompts + current query
222 $current_token_count = 0;
223
224 // Allowed HTML tags for content sanitization
225 $allowed_tags = [
226 'pre' => ['class' => true],
227 'code' => ['class' => true],
228 'span' => ['class' => true],
229 'div' => ['class' => true],
230 'strong' => [],
231 'em' => []
232 ];
233
234 foreach (array_reverse($history) as $entry) {
235 // Preserve code blocks while sanitizing other HTML
236 $clean_content = wp_kses($entry['content'], $allowed_tags);
237
238 // Detect code blocks in content
239 $has_code = false;
240 // Replace the HTML check with:
241 // Allow messages that contain code blocks or are plain text
242 if (strpos($clean_content, '<pre') === false &&
243 strpos($clean_content, '<code') === false &&
244 $clean_content !== strip_tags($entry['content'])) {
245 continue;
246 }
247
248 // Skip entries that lost significant content during sanitization
249 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
250 continue;
251 }
252
253 // More accurate token estimation (1 token ≈ 4 characters)
254 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
255
256 // Check token budget with the new estimate
257 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
258 // Try to fit partial content if it's the first entry
259 if (empty($formatted_history)) {
260 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
261 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
262 } else {
263 break;
264 }
265 }
266
267 // Add to formatted history
268 $formatted_history[] = [
269 'role' => $entry['role'],
270 'content' => $clean_content
271 ];
272
273 $current_token_count += $token_estimate;
274 }
275
276 // Reverse back to maintain chronological order
277 $formatted_history = array_reverse($formatted_history);
278
279 // Add system message about code context
280 array_unshift($formatted_history, [
281 'role' => 'system',
282 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
283 . 'Maintain formatting and syntax highlighting when referencing code.'
284 ]);
285
286 return $formatted_history;
287 }
288
289 public function register_routes() {
290 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
291
292 register_rest_route('mxchat/v1', '/stream', [
293 'methods' => 'GET',
294 'callback' => [$this, 'mxchat_stream_events'],
295 'permission_callback' => [$this, 'verify_chat_session'],
296 ]);
297
298 register_rest_route('mxchat/v1', '/agent-response', [
299 'methods' => 'POST',
300 'callback' => [$this, 'mxchat_handle_agent_response'],
301 'permission_callback' => [$this, 'verify_slack_request'],
302 ]);
303
304 register_rest_route('mxchat/v1', '/slack-interaction', [
305 'methods' => 'POST',
306 'callback' => [$this, 'handle_slack_interaction'],
307 'permission_callback' => [$this, 'verify_slack_request'],
308 ]);
309
310 register_rest_route('mxchat/v1', '/slack-messages', [
311 'methods' => 'POST',
312 'callback' => [$this, 'handle_slack_messages'],
313 'permission_callback' => [$this, 'verify_slack_request'],
314 ]);
315
316 // Telegram webhook endpoint
317 register_rest_route('mxchat/v1', '/telegram-webhook', [
318 'methods' => 'POST',
319 'callback' => [$this, 'handle_telegram_webhook'],
320 'permission_callback' => [$this, 'verify_telegram_request'],
321 ]);
322
323 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
324 }
325
326 /**
327 * Verify valid chat session
328 */
329 public function verify_chat_session($request) {
330 $session_id = $request->get_param('session_id');
331 if (empty($session_id)) {
332 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
333 return false;
334 }
335
336 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
337 return $chat_mode === 'agent';
338 }
339
340 /**
341 * Verify request is coming from Slack.
342 *
343 * @param WP_REST_Request $request
344 * @return bool True if valid, false otherwise.
345 */
346 public function verify_slack_request($request) {
347 // Get the Slack signing secret from your plugin options
348 $valid_key = $this->options['live_agent_secret_key'] ?? '';
349
350 if (empty($valid_key)) {
351 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
352 return false;
353 }
354
355 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
356 $slack_signature = $request->get_header('X-Slack-Signature');
357
358 // Verify timestamp to prevent replay attacks
359 if (abs(time() - intval($timestamp)) > 300) {
360 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
361 return false;
362 }
363
364 // Get raw request body from the WP_REST_Request object
365 // (php://input may already be consumed by WordPress at this point)
366 $request_body = $request->get_body();
367
368 // Create the signature base string
369 $sig_basestring = "v0:{$timestamp}:{$request_body}";
370
371 // Calculate expected signature
372 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
373
374 // Compare signatures
375 return hash_equals($my_signature, $slack_signature);
376 }
377
378 /**
379 * Verify request is coming from Telegram.
380 *
381 * @param WP_REST_Request $request
382 * @return bool True if valid, false otherwise.
383 */
384 public function verify_telegram_request($request) {
385 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
386
387 //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
388 //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
389
390 if (empty($secret_token)) {
391 // If no secret is configured, allow the request (for initial setup)
392 //error_log('[MxChat Telegram DEBUG] No secret configured, allowing request');
393 return true;
394 }
395
396 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
397 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
398
399 //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
400
401 if (empty($request_token)) {
402 //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
403 return false;
404 }
405
406 // Timing-safe comparison
407 $result = hash_equals($secret_token, $request_token);
408 //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
409 return $result;
410 }
411
412 public function mxchat_stream_events(WP_REST_Request $request) {
413 header('Content-Type: text/event-stream');
414 header('Cache-Control: no-cache');
415 header('Connection: keep-alive');
416
417 $session_id = sanitize_text_field($request->get_param('session_id'));
418 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
419
420 if (empty($session_id)) {
421 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
422 flush();
423 exit;
424 }
425
426 $history = get_option("mxchat_history_{$session_id}", []);
427
428 // Filter only new messages
429 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
430 return !empty($message['id']) && $message['id'] > $last_seen_id;
431 });
432
433 // Send new messages if available
434 if (!empty($new_messages)) {
435 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
436 } else {
437 // Keep the connection alive
438 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
439 }
440 flush();
441 exit;
442 }
443
444
445
446
447 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
448 global $wpdb;
449 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
450 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
451
452 // Check if this is the first message in a new session (before any other database operations)
453 $is_new_session = false;
454 if ($role === 'user') { // Only check for user messages, not bot responses
455 $existing_messages = $wpdb->get_var($wpdb->prepare(
456 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
457 $session_id
458 ));
459 $is_new_session = ($existing_messages == 0);
460
461 // Log for debugging
462 if ($is_new_session) {
463 //error_log("[DEBUG] This is a NEW session - first message");
464 }
465 }
466
467 // SECURITY FIX: Set session ownership for new sessions
468 if ($is_new_session && $role === 'user') {
469 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
470 $session_owner_key = "mxchat_session_owner_{$session_id}";
471
472 // Only set ownership if not already set
473 if (!get_option($session_owner_key)) {
474 update_option($session_owner_key, $current_user_identifier, 'no');
475 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
476 }
477 }
478
479 // 1) Extract agent name if present
480 $agent_name = '';
481 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
482 $agent_name = $matches[1];
483 $message = str_replace("Agent: $agent_name - ", '', $message);
484 $session_meta_key = "mxchat_agent_name_{$session_id}";
485 if (empty(get_option($session_meta_key))) {
486 update_option($session_meta_key, $agent_name);
487 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
488 }
489 }
490
491 // 2) Generate unique message_id
492 $message_id = uniqid();
493 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
494
495 // 3) Determine user_id
496 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
497
498 // 4) Determine user_identifier
499 $user_identifier = $agent_name
500 ? $agent_name
501 : MxChat_User::mxchat_get_user_identifier();
502
503 // 5) Determine displayed_name
504 $user_email = MxChat_User::mxchat_get_user_email();
505 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
506
507 // 6) Check for a saved email in wp_options
508 $email_option_key = "mxchat_email_{$session_id}";
509 $saved_email = get_option($email_option_key);
510 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
511
512 // Check for a saved name in wp_options
513 $name_option_key = "mxchat_name_{$session_id}";
514 $saved_name = get_option($name_option_key);
515 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
516
517 // If found, update DB user_email and user_name
518 if ($saved_email || $saved_name) {
519 $update_data = [];
520 if ($saved_email) {
521 $update_data['user_email'] = $saved_email;
522 }
523 if ($saved_name) {
524 $update_data['user_name'] = $saved_name;
525 }
526
527 if (!empty($update_data)) {
528 $update_res = $wpdb->update(
529 $table_name,
530 $update_data,
531 ['session_id' => $session_id],
532 array_fill(0, count($update_data), '%s'),
533 ['%s']
534 );
535 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
536 }
537 }
538
539 // 7) Save to session history in wp_options
540 $history_key = "mxchat_history_{$session_id}";
541 $history = get_option($history_key, []);
542 $history[] = [
543 'id' => $message_id,
544 'role' => $role,
545 'content' => $message,
546 'timestamp' => round(microtime(true) * 1000),
547 'agent_name' => $displayed_name,
548 ];
549 update_option($history_key, $history, 'no');
550 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
551
552 // 8) Save the message to DB (INSERT)
553 $insert_data = [
554 'user_id' => $user_id,
555 'user_identifier'=> $user_identifier,
556 'user_email' => $saved_email ?: $user_email,
557 'user_name' => $saved_name ?: '', // Add name to insert data
558 'session_id' => $session_id,
559 'role' => $role,
560 'message' => $message,
561 'timestamp' => current_time('mysql', 1),
562 ];
563
564 // IMPROVED: Handle originating page data
565 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
566
567 if ($columns_exist) {
568 if ($is_new_session && $role === 'user') {
569 // For the first user message, set originating page data
570
571 // First check if we have it from the parameter
572 if ($originating_page && !empty($originating_page['url'])) {
573 $insert_data['originating_page_url'] = $originating_page['url'];
574 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
575
576 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
577 }
578 // Otherwise check if it's stored in the instance property
579 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
580 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
581 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
582
583 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
584
585 // Clear after using
586 unset($this->pending_originating_page);
587 }
588 // Fallback to HTTP_REFERER if nothing else is available
589 else if (isset($_SERVER['HTTP_REFERER'])) {
590 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
591 $insert_data['originating_page_url'] = $referer_url;
592
593 // Generate title from URL
594 $parsed_url = parse_url($referer_url);
595 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
596
597 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
598 $insert_data['originating_page_title'] = 'Homepage';
599 } else {
600 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
601 $insert_data['originating_page_title'] = ucwords(trim($title));
602 }
603
604 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
605 }
606
607 // Store for this session so all messages have the same originating page
608 if (!empty($insert_data['originating_page_url'])) {
609 update_option("mxchat_originating_page_{$session_id}", [
610 'url' => $insert_data['originating_page_url'],
611 'title' => $insert_data['originating_page_title']
612 ], 'no');
613 }
614 } else {
615 // For subsequent messages in the session, use the stored originating page
616 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
617 if ($stored_originating && !empty($stored_originating['url'])) {
618 $insert_data['originating_page_url'] = $stored_originating['url'];
619 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
620 }
621 }
622 }
623
624 // Add RAG context if provided (for bot messages)
625 if ($rag_context !== null && $role === 'bot') {
626 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
627 if ($rag_context_column_exists) {
628 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
629 }
630 }
631
632 $wpdb->insert($table_name, $insert_data);
633 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
634
635 // 9) Send notification email if this is the first user message in a new session
636 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
637 $this->send_new_chat_notification($session_id, array(
638 'identifier' => $user_identifier,
639 'email' => $saved_email ?: $user_email,
640 'ip' => $_SERVER['REMOTE_ADDR']
641 ));
642 }
643
644 // 10) Schedule delayed transcript email if enabled and message is from user
645 if ($wpdb->insert_id && $role === 'user') {
646 $this->schedule_delayed_transcript_email($session_id);
647 }
648
649 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
650 return $message_id;
651 }
652
653 private function send_new_chat_notification($session_id, $user_info = array()) {
654 $options = get_option('mxchat_transcripts_options');
655
656 // Check if notifications are enabled
657 if (empty($options['mxchat_enable_notifications'])) {
658 return false;
659 }
660
661 // Get notification email
662 $to = !empty($options['mxchat_notification_email']) ?
663 $options['mxchat_notification_email'] :
664 get_option('admin_email');
665
666 if (!is_email($to)) {
667 return false;
668 }
669
670 // Prepare email content
671 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
672
673 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
674 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
675 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
676
677 $message = sprintf(
678 "A new chat session has started on your website.\n\n" .
679 "Session ID: %s\n" .
680 "User: %s\n" .
681 "Email: %s\n" .
682 "IP Address: %s\n" .
683 "Time: %s\n\n" .
684 "View transcripts: %s",
685 $session_id,
686 $user_identifier,
687 $user_email,
688 $user_ip,
689 current_time('mysql'),
690 admin_url('admin.php?page=mxchat-transcripts')
691 );
692
693 // Send email
694 return wp_mail($to, $subject, $message);
695 }
696
697 /**
698 * Schedule delayed transcript email for a session
699 * Reschedules if a new user message is received
700 */
701 private function schedule_delayed_transcript_email($session_id) {
702 $options = get_option('mxchat_transcripts_options');
703
704 // Check if auto-email is enabled
705 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
706 return;
707 }
708
709 // Get notification email
710 $email = !empty($options['mxchat_notification_email']) ?
711 $options['mxchat_notification_email'] :
712 get_option('admin_email');
713
714 if (!is_email($email)) {
715 return;
716 }
717
718 // Get delay in minutes (default 30)
719 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
720 intval($options['mxchat_auto_email_transcript_delay']) : 30;
721
722 // Clear any existing scheduled event for this session
723 $hook = 'mxchat_send_delayed_transcript';
724 $args = array($session_id);
725 $timestamp = wp_next_scheduled($hook, $args);
726
727 if ($timestamp) {
728 wp_unschedule_event($timestamp, $hook, $args);
729 }
730
731 // Schedule new event
732 $schedule_time = time() + ($delay_minutes * 60);
733 wp_schedule_single_event($schedule_time, $hook, $args);
734 }
735
736 /**
737 * Check if chat messages contain contact information (email or phone number)
738 *
739 * @param array $messages Array of message objects with 'message' property
740 * @param object|null $session_data Session data object with user_email property
741 * @return bool True if contact info found, false otherwise
742 */
743 private function chat_contains_contact_info($messages, $session_data = null) {
744 // Check if session already has a stored email
745 if ($session_data && !empty($session_data->user_email)) {
746 return true;
747 }
748
749 // Email regex pattern
750 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
751
752 // Phone number patterns (covers various formats including international, WhatsApp style)
753 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
754 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
755
756 // Only check user messages (not assistant responses)
757 foreach ($messages as $msg) {
758 if ($msg->role !== 'user') {
759 continue;
760 }
761
762 $message_text = $msg->message;
763
764 // Check for email
765 if (preg_match($email_pattern, $message_text)) {
766 return true;
767 }
768
769 // Check for phone number (must be at least 7 digits total to avoid false positives)
770 if (preg_match($phone_pattern, $message_text, $matches)) {
771 // Count actual digits to avoid matching short numbers
772 $digits_only = preg_replace('/\D/', '', $matches[0]);
773 if (strlen($digits_only) >= 7) {
774 return true;
775 }
776 }
777 }
778
779 return false;
780 }
781
782 /**
783 * Send the delayed transcript email with .txt attachment
784 */
785 public function mxchat_send_delayed_transcript($session_id) {
786 global $wpdb;
787
788 $options = get_option('mxchat_transcripts_options');
789
790 // Get notification email
791 $to = !empty($options['mxchat_notification_email']) ?
792 $options['mxchat_notification_email'] :
793 get_option('admin_email');
794
795 if (!is_email($to)) {
796 return false;
797 }
798
799 // Get all messages for this session
800 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
801 $messages = $wpdb->get_results($wpdb->prepare(
802 "SELECT role, message, timestamp FROM {$table_name}
803 WHERE session_id = %s
804 ORDER BY timestamp ASC",
805 $session_id
806 ));
807
808 if (empty($messages)) {
809 return false;
810 }
811
812 // Get session metadata
813 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
814 $session_data = $wpdb->get_row($wpdb->prepare(
815 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
816 $session_id
817 ));
818
819 // Check if contact info is required and if it's present
820 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
821 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
822 // Contact info required but not found - skip sending
823 return false;
824 }
825
826 // Build transcript content
827 $transcript_content = "Chat Transcript\n";
828 $transcript_content .= "================\n\n";
829 $transcript_content .= "Session ID: " . $session_id . "\n";
830
831 if ($session_data) {
832 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
833 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
834 $transcript_content .= "Started: " . $session_data->created_at . "\n";
835 }
836
837 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
838
839 // Add messages
840 foreach ($messages as $msg) {
841 $role_label = ($msg->role === 'user') ? 'User' : 'Assistant';
842 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
843 $transcript_content .= $msg->message . "\n\n";
844 }
845
846 // Create temporary file for attachment using WP_Filesystem
847 $upload_dir = wp_upload_dir();
848 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
849 global $wp_filesystem;
850 if (empty($wp_filesystem)) {
851 require_once ABSPATH . 'wp-admin/includes/file.php';
852 WP_Filesystem();
853 }
854 $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
855
856 // Prepare email
857 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
858
859 $message = "Please find attached the full chat transcript.\n\n";
860 $message .= "Session ID: {$session_id}\n";
861
862 if ($session_data) {
863 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
864 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
865 }
866
867 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
868
869 // Send email with attachment
870 $attachments = array($temp_file);
871 $result = wp_mail($to, $subject, $message, '', $attachments);
872
873 // Clean up temporary file
874 if (file_exists($temp_file)) {
875 unlink($temp_file);
876 }
877
878 return $result;
879 }
880
881
882
883 public function mxchat_handle_save_email_and_response() {
884 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
885 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
886
887 nocache_headers();
888
889 // Validate nonce
890 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
891 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
892 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
893 wp_die();
894 }
895
896 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
897 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
898 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
899
900 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
901
902 if (empty($session_id) || $session_id === 'null' || empty($email)) {
903 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
904 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
905 wp_die();
906 }
907
908 // Validate name if provided (check if name field is enabled and name is required)
909 $options = get_option('mxchat_options', []);
910 $name_field_enabled = isset($options['enable_name_field']) &&
911 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
912
913 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
914 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
915 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
916 wp_die();
917 }
918
919 // 1) Always store email in wp_options
920 $email_option_key = "mxchat_email_{$session_id}";
921 update_option($email_option_key, $email, 'no');
922 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
923
924 // Store name in wp_options if provided
925 if (!empty($name)) {
926 $name_option_key = "mxchat_name_{$session_id}";
927 update_option($name_option_key, $name, 'no');
928 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
929 }
930
931 // 2) (Optional) Also store in DB if a row already exists
932 global $wpdb;
933 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
934
935 // Make sure we have a valid placeholder in prepare
936 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
937 $session_count = $wpdb->get_var($sql);
938
939 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
940
941 if ($session_count) {
942 // Update both user_email and user_name if row(s) exist
943 if (!empty($name)) {
944 $update_sql = $wpdb->prepare(
945 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
946 $email,
947 $name,
948 $session_id
949 );
950 } else {
951 $update_sql = $wpdb->prepare(
952 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
953 $email,
954 $session_id
955 );
956 }
957 $wpdb->query($update_sql);
958 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
959 } else {
960 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
961 }
962
963 // Provide success response (same as original)
964 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
965 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
966 wp_send_json_success(['message' => $bot_message]);
967 wp_die();
968 }
969
970 public function mxchat_check_email_provided() {
971 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
972
973 nocache_headers();
974
975 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
976 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
977 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
978 }
979
980 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
981 if (empty($session_id) || $session_id === 'null') {
982 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
983 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
984 }
985
986 // Check if the user is logged in
987 if (is_user_logged_in()) {
988 $current_user = wp_get_current_user();
989 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
990
991 // Get user's display name for logged in users
992 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
993 (!empty($current_user->first_name) ? $current_user->first_name : '');
994
995 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
996 if (!empty($user_name)) {
997 $response_data['name'] = $user_name;
998 }
999
1000 wp_send_json_success($response_data);
1001 }
1002
1003 // Check if name field is required
1004 $options = get_option('mxchat_options', []);
1005 $name_field_enabled = isset($options['enable_name_field']) &&
1006 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1007
1008 $email_option_key = "mxchat_email_{$session_id}";
1009 $stored_email = get_option($email_option_key, '');
1010
1011 // Check for stored name
1012 $name_option_key = "mxchat_name_{$session_id}";
1013 $stored_name = get_option($name_option_key, '');
1014
1015 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1016 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1017
1018 // Check if we have email and name (if name is required)
1019 $has_required_info = !empty($stored_email);
1020
1021 if ($name_field_enabled) {
1022 $has_required_info = $has_required_info && !empty($stored_name);
1023 }
1024
1025 if ($has_required_info) {
1026 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1027
1028 $response_data = ['email' => $stored_email];
1029 if (!empty($stored_name)) {
1030 $response_data['name'] = $stored_name;
1031 }
1032
1033 wp_send_json_success($response_data);
1034 } else {
1035 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1036 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1037 }
1038 }
1039
1040 /**
1041 * Send error response in appropriate format based on streaming mode
1042 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1043 *
1044 * @param string $error_message The error message to display
1045 * @param string $error_code Optional error code for debugging
1046 */
1047 private function send_error_response($error_message, $error_code = 'api_error') {
1048 if ($this->is_streaming) {
1049 echo "data: " . json_encode([
1050 'error' => true,
1051 'error_message' => $error_message,
1052 'error_code' => $error_code,
1053 'text' => $error_message,
1054 'message' => $error_message
1055 ]) . "\n\n";
1056 echo "data: [DONE]\n\n";
1057 flush();
1058 } else {
1059 wp_send_json_error([
1060 'error_message' => $error_message,
1061 'error_code' => $error_code
1062 ]);
1063 }
1064 wp_die();
1065 }
1066
1067 public function mxchat_handle_chat_request() {
1068 global $wpdb;
1069
1070 // Debug: Log incoming bot_id
1071 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1072 //error_log("=== MXCHAT DEBUG: Starting chat request ===");
1073 //error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1074
1075 // Get bot-specific options
1076 $bot_options = $this->get_bot_options($bot_id);
1077 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1078
1079 // Check if this is a streaming request
1080 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1081 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1082 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1083 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1084
1085 // ADDED: Store streaming state in class property for use in private methods
1086 $this->is_streaming = $is_streaming;
1087
1088 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1089 // This allows actions/forms to return JSON responses without header conflicts
1090
1091 // Check if MX Chat Moderation is active
1092 if (class_exists('MX_Chat_Moderation')) {
1093 // Get user email and IP
1094 $user_email = '';
1095 $user_ip = $_SERVER['REMOTE_ADDR'];
1096
1097 // If user is logged in, get their email
1098 if (is_user_logged_in()) {
1099 $current_user = wp_get_current_user();
1100 $user_email = $current_user->user_email;
1101 }
1102
1103 // Create ban handler instance
1104 $ban_handler = new MX_Chat_Ban_Handler();
1105
1106 // Check if user is banned by IP
1107 if ($ban_handler->check_ban($user_ip, 'ip')) {
1108 wp_send_json([
1109 'success' => false,
1110 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1111 'status' => 'banned'
1112 ]);
1113 wp_die();
1114 }
1115
1116 // If user is logged in, also check email
1117 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1118 wp_send_json([
1119 'success' => false,
1120 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1121 'status' => 'banned'
1122 ]);
1123 wp_die();
1124 }
1125 }
1126
1127 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1128 $this->productCardHtml = '';
1129
1130 // Get the actual WordPress user ID if logged in
1131 $is_logged_in = is_user_logged_in();
1132 if ($is_logged_in) {
1133 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1134 } else {
1135 // For logged-out users, use your existing identifier method
1136 $user_id = $this->mxchat_get_user_identifier();
1137 }
1138
1139 // Get and sanitize the user identifier
1140 $user_id = sanitize_key($user_id);
1141
1142 // Check rate limit using new settings structure
1143 $rate_limit_result = $this->check_rate_limit();
1144
1145 if ($rate_limit_result !== true) {
1146 wp_send_json([
1147 'success' => false,
1148 'message' => $rate_limit_result['message'],
1149 'status' => 'rate_limit_exceeded'
1150 ]);
1151 wp_die();
1152 }
1153
1154 // Rest of your existing code...
1155 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1156
1157 if (empty($session_id)) {
1158 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1159 wp_die();
1160 }
1161
1162 // SECURITY FIX: Verify session ownership before processing chat request
1163 // If IP/user changed, signal frontend to reset session instead of blocking
1164 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1165 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1166
1167 if ($session_owner && $session_owner !== $current_user_identifier) {
1168 // Instead of blocking, tell frontend to start a fresh session
1169 wp_send_json_error([
1170 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
1171 'code' => 'session_expired',
1172 'action' => 'reset_session'
1173 ]);
1174 wp_die();
1175 }
1176
1177 // Validate and sanitize the incoming message
1178 if (empty($_POST['message'])) {
1179 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1180 wp_die();
1181 }
1182
1183
1184 // Track originating page for first message in session
1185 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1186
1187 // Check if originating page columns exist
1188 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1189
1190 if ($columns_exist) {
1191 // Check if this session already has messages
1192 $message_count = $wpdb->get_var($wpdb->prepare(
1193 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1194 $session_id
1195 ));
1196
1197 // If this is the first message in the session
1198 if ($message_count == 0) {
1199 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1200 $originating_url = '';
1201 $originating_title = '';
1202
1203 // Try to get from POST data first (sent by JavaScript)
1204 if (isset($_POST['current_page_url'])) {
1205 $originating_url = esc_url_raw($_POST['current_page_url']);
1206 $originating_title = isset($_POST['current_page_title'])
1207 ? sanitize_text_field($_POST['current_page_title'])
1208 : '';
1209 }
1210 // Fallback to HTTP_REFERER if not provided by JavaScript
1211 else if (isset($_SERVER['HTTP_REFERER'])) {
1212 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1213 }
1214
1215 // Generate title if we have URL but no title
1216 if ($originating_url && empty($originating_title)) {
1217 $parsed_url = parse_url($originating_url);
1218 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1219
1220 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1221 $originating_title = 'Homepage';
1222 } else {
1223 // Clean up the path to make a readable title
1224 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1225 $originating_title = ucwords(trim($originating_title));
1226 }
1227 }
1228
1229 // Store for later use when saving the message
1230 $this->pending_originating_page = [
1231 'url' => $originating_url,
1232 'title' => $originating_title
1233 ];
1234 }
1235 }
1236
1237
1238
1239 // Get page context if provided
1240 $page_context = null;
1241 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1242 $page_context_raw = stripslashes($_POST['page_context']);
1243 $page_context = json_decode($page_context_raw, true);
1244
1245 // Validate page context structure
1246 if (is_array($page_context) &&
1247 isset($page_context['url']) &&
1248 isset($page_context['title']) &&
1249 isset($page_context['content'])) {
1250
1251 // Sanitize page context
1252 $page_context['url'] = esc_url_raw($page_context['url']);
1253 $page_context['title'] = sanitize_text_field($page_context['title']);
1254 $page_context['content'] = wp_kses_post($page_context['content']);
1255 } else {
1256 $page_context = null;
1257 }
1258 }
1259
1260 // Modify the message sanitization to preserve PHP tags in code blocks
1261 $allowed_tags = [
1262 'pre' => [],
1263 'code' => ['class' => true],
1264 'span' => ['class' => true],
1265 'div' => ['class' => true],
1266 ];
1267
1268 // First preserve code blocks
1269 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1270 return htmlspecialchars_decode($matches[0]);
1271 }, $_POST['message']);
1272
1273 // Then apply sanitization
1274 $message = wp_kses($message, $allowed_tags);
1275
1276 // Preserve code blocks from markdown conversion
1277 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1278 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1279
1280 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1281 // Always initialize testing data for admins (no toggle needed)
1282 $testing_data = null;
1283 if (current_user_can('administrator')) {
1284 // For vision messages, use the original user message for the query display
1285 $query_for_testing = $message;
1286 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1287 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1288 }
1289
1290 $testing_data = [
1291 'query' => $query_for_testing,
1292 'timestamp' => time(),
1293 'top_matches' => [],
1294 'action_matches' => [], // Initialize action matches array
1295 'page_context' => $page_context, // Include page context in testing data
1296 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1297 'bot_id' => $bot_id // Include bot ID in testing data
1298 ];
1299
1300 // Get similarity threshold from bot options or default options
1301 $similarity_threshold = isset($current_options['similarity_threshold'])
1302 ? ((int) $current_options['similarity_threshold']) / 100
1303 : 0.35;
1304
1305 $testing_data['similarity_threshold'] = $similarity_threshold;
1306
1307 // Determine knowledge base type using bot-specific config
1308 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1309 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1310 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1311 }
1312 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1313
1314 // Add debug before and after:
1315 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1316 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1317 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1318
1319
1320 // If the pre-processing returned a result (not the original message), use it directly
1321 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1322 // Save the AI response
1323 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1324
1325 // Save HTML content if provided
1326 if (!empty($pre_processed_result['html'])) {
1327 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1328 }
1329
1330 // Add testing data if admin
1331 $response_data = [
1332 'text' => $pre_processed_result['text'],
1333 'html' => $pre_processed_result['html'] ?? '',
1334 'session_id' => $session_id
1335 ];
1336
1337 if ($testing_data !== null) {
1338 $response_data['testing_data'] = $testing_data;
1339 }
1340
1341 wp_send_json($response_data);
1342 wp_die();
1343 }
1344
1345 // Save the user's message - handle vision processed messages differently
1346 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1347 // For vision messages, save the original user message with image indicator
1348 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1349 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1350 $image_count = intval($_POST['vision_images_count']);
1351 $original_message .= " [{$image_count} image(s)]";
1352 }
1353 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1354 } else {
1355 // Regular message - save as normal
1356 $this->mxchat_save_chat_message($session_id, 'user', $message);
1357 }
1358
1359
1360 if (is_email($message)) {
1361 // Add the email to Loops
1362 $this->add_email_to_loops($message);
1363
1364 // Get the user's success message instruction using current_options
1365 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1366
1367 // Set instruction for AI using the user's success message
1368 $this->current_action_instruction = $user_success_message;
1369
1370 // Clear the email capture transient since we got the email
1371 delete_transient('mxchat_email_capture_' . $user_id);
1372 }
1373
1374 // Check if we're in an email capture flow but user hasn't provided email yet
1375 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1376 // Check if the message contains an email (not the whole message being an email)
1377 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1378 $extracted_email = $matches[0];
1379
1380 // Add the extracted email to Loops
1381 $this->add_email_to_loops($extracted_email);
1382
1383 // Get the user's success message instruction using current_options
1384 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1385
1386 // Set instruction for AI using the user's success message
1387 $this->current_action_instruction = $user_success_message;
1388
1389 // Clear the email capture transient since we got the email
1390 delete_transient('mxchat_email_capture_' . $user_id);
1391 }
1392 // If no email found but we're in capture mode, remind them
1393 else {
1394 // Get the original instruction to remind them using current_options
1395 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1396 $this->current_action_instruction = $original_instruction;
1397 }
1398 }
1399
1400 $intent_info = '';
1401
1402 // Check chat mode
1403 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1404
1405 // Handle agent mode
1406 // Handle agent mode
1407 if ($chat_mode === 'agent') {
1408 // First, check for switch intent before doing anything else
1409 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1410
1411 // Capture action analysis for testing panel after intent check
1412 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1413 $testing_data['action_matches'] = $this->last_action_analysis;
1414 }
1415
1416 // Around line 506, in the agent mode handling section:
1417 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1418 // Update chat mode first
1419 update_option("mxchat_mode_{$session_id}", 'ai');
1420
1421 // Clear any existing PDF context to start fresh
1422 $this->clear_pdf_transients($session_id);
1423
1424 // Prepare clean switch response with explicit chat_mode
1425 $response_data = [
1426 'text' => $this->fallbackResponse['text'],
1427 'html' => $this->fallbackResponse['html'] ?? '',
1428 'session_id' => $session_id,
1429 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1430 ];
1431
1432 if ($testing_data !== null) {
1433 $response_data['testing_data'] = $testing_data;
1434 }
1435
1436 // Save the mode switch message
1437 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1438 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1439
1440 // Send response and exit
1441 wp_send_json($response_data);
1442 wp_die();
1443 } elseif (!$intent_matched) {
1444 // No intent matched, handle live agent message
1445 try {
1446 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1447
1448 $agent_response = [
1449 'status' => 'waiting_for_agent',
1450 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1451 ];
1452
1453 if ($testing_data !== null) {
1454 $agent_response['testing_data'] = $testing_data;
1455 }
1456
1457 wp_send_json_success($agent_response);
1458 } catch (\Exception $e) {
1459 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1460 }
1461 wp_die();
1462 }
1463 }
1464
1465 // Step 1: Check for new PDF URL in the message
1466 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1467 $new_pdf_url = $matches[0];
1468
1469 // Check if this is likely a PDF-related request
1470 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1471 $is_pdf_request = false;
1472
1473 foreach ($pdf_keywords as $keyword) {
1474 if (stripos($message, $keyword) !== false) {
1475 $is_pdf_request = true;
1476 break;
1477 }
1478 }
1479
1480 // If it looks like a PDF request or we're waiting for a PDF URL
1481 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1482 // Validate HTTPS
1483 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1484 // Extract filename from URL
1485 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1486
1487 // Clear previous PDF transients
1488 $this->clear_pdf_transients($session_id);
1489
1490 // Process new PDF using current_options
1491 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1492 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1493
1494 if ($embeddings === 'too_many_pages') {
1495 $error_text = sprintf(
1496 $current_options['pdf_intent_error_text'] ??
1497 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1498 $max_pages
1499 );
1500 $this->fallbackResponse['text'] = $error_text;
1501 } elseif ($embeddings) {
1502 // Store new PDF information
1503 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1504
1505 // If the filename is generic, create a more descriptive one
1506 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1507 strpos($pdf_filename, '.php') !== false) {
1508 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1509 }
1510
1511 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1512 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1513 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1514 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1515
1516 $success_text = $current_options['pdf_intent_success_text'] ??
1517 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1518
1519 $pdf_response = [
1520 'success' => true,
1521 'message' => $success_text,
1522 'data' => [
1523 'filename' => $pdf_filename
1524 ]
1525 ];
1526
1527 if ($testing_data !== null) {
1528 $pdf_response['testing_data'] = $testing_data;
1529 }
1530
1531 wp_send_json($pdf_response);
1532 wp_die();
1533 } else {
1534 $error_text = $current_options['pdf_intent_error_text'] ??
1535 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1536 $this->fallbackResponse['text'] = $error_text;
1537 }
1538
1539 $pdf_error_response = [
1540 'success' => false,
1541 'message' => $this->fallbackResponse['text']
1542 ];
1543
1544 if ($testing_data !== null) {
1545 $pdf_error_response['testing_data'] = $testing_data;
1546 }
1547
1548 wp_send_json($pdf_error_response);
1549 wp_die();
1550 }
1551 }
1552 }
1553
1554
1555 // Step 2: Detect intent and handle intent-based responses
1556 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1557
1558 // Capture action analysis for testing panel after intent check
1559 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1560 $testing_data['action_matches'] = $this->last_action_analysis;
1561 }
1562
1563 // Step 3: Handle the intent result appropriately
1564 if ($intent_result !== false) {
1565 // Intent was matched - ALWAYS send as JSON response, never streaming
1566
1567 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1568 // Intent returned a direct response array
1569 $response_data = [
1570 'text' => $intent_result['text'] ?? '',
1571 'html' => $intent_result['html'] ?? '',
1572 'session_id' => $session_id
1573 ];
1574
1575 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
1576 if (isset($intent_result['chat_mode'])) {
1577 $response_data['chat_mode'] = $intent_result['chat_mode'];
1578 }
1579
1580 if ($testing_data !== null) {
1581 $response_data['testing_data'] = $testing_data;
1582 }
1583
1584 wp_send_json($response_data);
1585 wp_die();
1586 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1587 // Intent returned true and set fallbackResponse
1588
1589 // SAVE TO TRANSCRIPT
1590 if (!empty($this->fallbackResponse['text'])) {
1591 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1592 }
1593 // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
1594 if (!empty($this->fallbackResponse['html'])) {
1595 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1596 }
1597
1598 $response_data = [
1599 'text' => $this->fallbackResponse['text'] ?? '',
1600 'html' => $this->fallbackResponse['html'] ?? '',
1601 'session_id' => $session_id
1602 ];
1603
1604 if (isset($this->fallbackResponse['chat_mode'])) {
1605 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
1606 }
1607
1608 if ($testing_data !== null) {
1609 $response_data['testing_data'] = $testing_data;
1610 }
1611
1612 wp_send_json($response_data);
1613 wp_die();
1614 }
1615 }
1616
1617 // If we get here, no intent matched OR the intent didn't provide a usable response
1618
1619 // Step 4: Generate AI response
1620 // Get session start timestamp - when persistence is OFF, only include messages from this page load
1621 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
1622 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
1623 $this->mxchat_increment_chat_count();
1624
1625 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
1626 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
1627 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
1628
1629 // Check if the embedding generation returned an error
1630 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
1631 $error_message = $user_message_embedding['error'];
1632 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
1633
1634 // FIXED: Send error in appropriate format based on streaming mode
1635 if ($is_streaming) {
1636 echo "data: " . json_encode([
1637 'error' => true,
1638 'error_message' => $error_message,
1639 'error_code' => $error_code,
1640 'text' => $error_message,
1641 'message' => $error_message
1642 ]) . "\n\n";
1643 echo "data: [DONE]\n\n";
1644 flush();
1645 } else {
1646 wp_send_json_error([
1647 'error_message' => $error_message,
1648 'error_code' => $error_code
1649 ]);
1650 }
1651 wp_die();
1652 }
1653
1654 // Check if the embedding is valid
1655 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
1656 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1657
1658 // FIXED: Send error in appropriate format based on streaming mode
1659 if ($is_streaming) {
1660 echo "data: " . json_encode([
1661 'error' => true,
1662 'error_message' => $error_message,
1663 'error_code' => 'invalid_embedding',
1664 'text' => $error_message,
1665 'message' => $error_message
1666 ]) . "\n\n";
1667 echo "data: [DONE]\n\n";
1668 flush();
1669 } else {
1670 wp_send_json_error([
1671 'error_message' => $error_message,
1672 'error_code' => 'invalid_embedding'
1673 ]);
1674 }
1675 wp_die();
1676 }
1677
1678 // Build context with both knowledge base and PDF content if available
1679 $context_content = "User asked: '{$message}'\n\n";
1680
1681 // Add action instruction if present (add this right after the above line)
1682 if (!empty($this->current_action_instruction)) {
1683 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
1684 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
1685 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
1686 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1687
1688 // Clear the instruction after using it
1689 $this->current_action_instruction = null;
1690 }
1691
1692
1693 // Add page context if available and contextual awareness is enabled using current_options
1694 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
1695 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
1696 $context_content .= "Page URL: " . $page_context['url'] . "\n";
1697 $context_content .= "Page Title: " . $page_context['title'] . "\n";
1698 $context_content .= "Page Content: " . $page_context['content'] . "\n";
1699 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
1700 }
1701
1702 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
1703 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
1704
1705 // NEW: Also extract URLs from system instructions (only if citation links enabled)
1706 // Use fresh options to ensure we get the latest setting value
1707 $fresh_options = get_option('mxchat_options', []);
1708 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
1709
1710 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
1711 if ($citation_links_enabled && !empty($system_instructions)) {
1712 preg_match_all(
1713 '#\bhttps?://[^\s<>"\']+#i',
1714 $system_instructions,
1715 $system_instruction_urls
1716 );
1717
1718 if (!empty($system_instruction_urls[0])) {
1719 // Merge with existing valid URLs
1720 $this->current_valid_urls = array_merge(
1721 $this->current_valid_urls,
1722 $system_instruction_urls[0]
1723 );
1724 // Remove duplicates
1725 $this->current_valid_urls = array_unique($this->current_valid_urls);
1726
1727 //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
1728 }
1729 }
1730
1731 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
1732 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
1733 // Update testing data with the REAL similarity analysis
1734 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
1735 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1736 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
1737 $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
1738 $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
1739 }
1740 // ===== END SIMILARITY DATA CAPTURE =====
1741
1742 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
1743 if ($testing_data !== null && !empty($this->current_valid_urls)) {
1744 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
1745 //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
1746 }
1747
1748 if (!empty($relevant_content)) {
1749 $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
1750 } else {
1751 $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
1752 }
1753
1754 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
1755 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
1756 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
1757 $context_content .= "You may ONLY use these exact URLs in your response:\n";
1758 foreach ($this->current_valid_urls as $url) {
1759 $context_content .= "- " . $url . "\n";
1760 }
1761 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
1762 $context_content .= "===== END APPROVED URLS =====\n\n";
1763 }
1764
1765 // Check for and include PDF content
1766 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
1767 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
1768 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
1769 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
1770 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
1771 if (!empty($relevant_pdf_pages)) {
1772 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
1773 foreach ($relevant_pdf_pages as $page_data) {
1774 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
1775 }
1776 $context_content .= "\n";
1777 }
1778 }
1779
1780 // Check for and include Word content
1781 $word_url = get_transient('mxchat_word_url_' . $session_id);
1782 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
1783 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
1784 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
1785 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
1786 if (!empty($relevant_word_chunks)) {
1787 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
1788 foreach ($relevant_word_chunks as $chunk_data) {
1789 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
1790 }
1791 $context_content .= "\n";
1792 }
1793 }
1794
1795 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
1796
1797 // Extract model from current options for bot-specific model support
1798 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.1-chat-latest';
1799
1800 $response = $this->mxchat_generate_response(
1801 $context_content,
1802 $current_options['api_key'] ?? $this->options['api_key'],
1803 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
1804 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
1805 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
1806 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
1807 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
1808 $conversation_history,
1809 $is_streaming,
1810 $session_id,
1811 $testing_data,
1812 $selected_model
1813 );
1814
1815 // Handle streaming vs non-streaming responses
1816 if ($is_streaming) {
1817 // Check if streaming actually happened or if it fell back to regular response
1818 if ($response === true) {
1819 wp_die();
1820 }
1821 // If we get here, streaming fell back to regular response, continue
1822 // But if there's an error, we need to send it as SSE format since headers are already set
1823 if (is_array($response) && isset($response['error'])) {
1824 $error_message = $response['error'];
1825 $error_code = $response['error_code'] ?? 'api_error';
1826 // Send error in SSE format that the client JS can handle
1827 echo "data: " . json_encode([
1828 'error' => true,
1829 'error_message' => $error_message,
1830 'error_code' => $error_code,
1831 'text' => $error_message, // Also include as text for fallback handling
1832 'message' => $error_message
1833 ]) . "\n\n";
1834 echo "data: [DONE]\n\n";
1835 flush();
1836 wp_die();
1837 }
1838 }
1839
1840 // Check if the response is an error array (non-streaming mode)
1841 if (is_array($response) && isset($response['error'])) {
1842 wp_send_json_error([
1843 'error_message' => $response['error'],
1844 'error_code' => $response['error_code'] ?? 'api_error'
1845 ]);
1846 wp_die();
1847 }
1848
1849 // DEBUG: Check what we have
1850 //error_log("=== BEFORE URL VALIDATION ===");
1851 //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
1852 //error_log("current_valid_urls count: " . count($this->current_valid_urls));
1853 //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
1854
1855 // If we get here, the response is valid text - now validate URLs
1856 if (!empty($this->current_valid_urls)) {
1857 //error_log("CALLING validate_and_clean_urls");
1858 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls);
1859 } else {
1860 //error_log("SKIPPING validation - current_valid_urls is empty");
1861 }
1862 // ===== END URL VALIDATION =====
1863
1864 // Prepare RAG context data for storage (only include documents used for context)
1865 $rag_context_for_storage = null;
1866 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
1867 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
1868
1869 if ($has_rag_data || $has_action_data) {
1870 $rag_context_for_storage = [];
1871
1872 // Add RAG/source data if available
1873 if ($has_rag_data) {
1874 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
1875 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
1876 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
1877 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
1878 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1879 $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
1880 $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
1881 }
1882
1883 // Add action analysis data if available
1884 if ($has_action_data) {
1885 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
1886 }
1887 }
1888
1889 // Save the cleaned response with RAG context
1890 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
1891
1892 // Step 5: Save additional content if available
1893 if (!empty($this->productCardHtml)) {
1894 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
1895 }
1896
1897 if (!empty($this->fallbackResponse['html'])) {
1898 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1899 }
1900
1901 // Step 6: Return the response
1902 // DEBUG: Check if newlines exist in the response
1903 //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
1904 //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
1905 //error_log("Response first 500 chars: " . substr($response, 0, 500));
1906
1907 $response_data = [
1908 'text' => $response,
1909 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
1910 'session_id' => $session_id
1911 ];
1912
1913 // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
1914 if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
1915 $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
1916 }
1917
1918 // Also pass it as a top-level field so JS can show a better error message to admins
1919 if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
1920 $response_data['vectorstore_error'] = $this->last_vectorstore_error;
1921 }
1922
1923 // Always add testing data for admins (no toggle needed)
1924 if ($testing_data !== null) {
1925 $response_data['testing_data'] = $testing_data;
1926 }
1927
1928 wp_send_json($response_data);
1929 wp_die();
1930 }
1931
1932 /**
1933 * Get bot-specific options for multi-bot functionality
1934 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
1935 */
1936 // Also debug the bot options retrieval
1937 private function get_bot_options($bot_id = 'default') {
1938 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
1939
1940 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1941 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
1942 return array();
1943 }
1944
1945 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1946
1947 if (!empty($bot_options)) {
1948 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
1949 if (isset($bot_options['similarity_threshold'])) {
1950 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
1951 }
1952 }
1953
1954 return is_array($bot_options) ? $bot_options : array();
1955 }
1956
1957 /**
1958 * Get bot-specific Pinecone configuration
1959 * Used in the knowledge retrieval functions
1960 */
1961 // Also add debugging to your get_bot_pinecone_config function
1962 private function get_bot_pinecone_config($bot_id = 'default') {
1963 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
1964
1965 // If default bot or multi-bot add-on not active, use default Pinecone config
1966 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1967 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
1968 $addon_options = get_option('mxchat_pinecone_addon_options', array());
1969 $config = array(
1970 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
1971 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
1972 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
1973 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
1974 );
1975 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
1976 return $config;
1977 }
1978
1979 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
1980
1981 // Hook for multi-bot add-on to provide bot-specific Pinecone config
1982 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1983
1984 if (!empty($bot_pinecone_config)) {
1985 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
1986 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
1987 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
1988 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
1989 } else {
1990 //error_log("MXCHAT DEBUG: Filter returned empty config!");
1991 }
1992
1993 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
1994 }
1995
1996
1997 // Updated function to check intents and invoke the callback function
1998 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
1999 global $wpdb;
2000 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
2001
2002 // Get the current bot_id
2003 $current_bot_id = $this->get_current_bot_id($session_id);
2004
2005 // Generate the user embedding
2006 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
2007
2008 // Check if embedding generation returned an error
2009 if (is_array($user_embedding) && isset($user_embedding['error'])) {
2010 $error_message = $user_embedding['error'];
2011 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
2012
2013 // FIXED: Send error in appropriate format based on streaming mode
2014 if ($this->is_streaming) {
2015 echo "data: " . json_encode([
2016 'error' => true,
2017 'error_message' => $error_message,
2018 'error_code' => $error_code,
2019 'text' => $error_message,
2020 'message' => $error_message
2021 ]) . "\n\n";
2022 echo "data: [DONE]\n\n";
2023 flush();
2024 } else {
2025 wp_send_json_error([
2026 'error_message' => $error_message,
2027 'error_code' => $error_code
2028 ]);
2029 }
2030 wp_die();
2031 }
2032
2033 // Check if embedding is valid
2034 if (!is_array($user_embedding) || empty($user_embedding)) {
2035 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2036
2037 // FIXED: Send error in appropriate format based on streaming mode
2038 if ($this->is_streaming) {
2039 echo "data: " . json_encode([
2040 'error' => true,
2041 'error_message' => $error_message,
2042 'error_code' => 'invalid_embedding',
2043 'text' => $error_message,
2044 'message' => $error_message
2045 ]) . "\n\n";
2046 echo "data: [DONE]\n\n";
2047 flush();
2048 } else {
2049 wp_send_json_error([
2050 'error_message' => $error_message,
2051 'error_code' => 'invalid_embedding'
2052 ]);
2053 }
2054 wp_die();
2055 }
2056
2057 // Fetch intents from the database
2058 $table_name = $wpdb->prefix . 'mxchat_intents';
2059 if ($chat_mode === 'agent') {
2060 $query = $wpdb->prepare(
2061 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2062 'mxchat_handle_switch_to_chatbot_intent'
2063 );
2064 $intents = $wpdb->get_results($query);
2065 } else {
2066 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2067 }
2068
2069 if (empty($intents)) {
2070 return false;
2071 }
2072
2073 $highest_similarity = -INF;
2074 $matched_intent = null;
2075
2076 // Array to store action analysis for testing panel
2077 $action_analysis = [];
2078
2079 foreach ($intents as $intent) {
2080 // Additional check for enabled state
2081 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2082 if (!$is_enabled) {
2083 continue;
2084 }
2085
2086 // Check if this action is enabled for the current bot
2087 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2088 continue;
2089 }
2090
2091 $intent_embedding_serialized = $intent->embedding_vector;
2092 $intent_embedding = $intent_embedding_serialized
2093 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2094 : null;
2095
2096 if (!is_array($intent_embedding)) {
2097 continue;
2098 }
2099
2100 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2101 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2102
2103 // Store action analysis data for testing panel
2104 $action_analysis[] = [
2105 'intent_label' => $intent->intent_label,
2106 'callback_function' => $intent->callback_function,
2107 'similarity' => round($similarity, 4),
2108 'similarity_percentage' => round($similarity * 100, 2),
2109 'threshold' => $intent_threshold,
2110 'threshold_percentage' => round($intent_threshold * 100, 2),
2111 'above_threshold' => $similarity >= $intent_threshold,
2112 'triggered' => false // Will be updated below if this intent is triggered
2113 ];
2114
2115 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2116 $highest_similarity = $similarity;
2117 $matched_intent = $intent;
2118 }
2119 }
2120
2121 // Mark the triggered action if any
2122 if ($matched_intent) {
2123 foreach ($action_analysis as &$action) {
2124 if ($action['intent_label'] === $matched_intent->intent_label) {
2125 $action['triggered'] = true;
2126 break;
2127 }
2128 }
2129 }
2130
2131 // Sort actions by similarity (highest first) and store for testing panel
2132 usort($action_analysis, function($a, $b) {
2133 return $b['similarity'] <=> $a['similarity'];
2134 });
2135
2136 // Store action analysis for testing panel capture
2137 $this->last_action_analysis = $action_analysis;
2138
2139 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2140 if ($matched_intent) {
2141 // If the callback is a method on this instance (core callback), call it directly
2142 if (method_exists($this, $matched_intent->callback_function)) {
2143 $callback_result = call_user_func(
2144 [$this, $matched_intent->callback_function],
2145 $message,
2146 $user_id,
2147 $session_id,
2148 $matched_intent,
2149 $user_context ?? null
2150 );
2151 } else {
2152 // Otherwise, use apply_filters for add-on callbacks
2153 $callback_result = apply_filters(
2154 $matched_intent->callback_function,
2155 false,
2156 $message,
2157 $user_id,
2158 $session_id,
2159 $matched_intent
2160 );
2161 }
2162
2163 // Handle the callback result properly
2164 if ($callback_result !== false) {
2165 // If callback returned an array with chat_mode, use it directly
2166 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2167 $this->fallbackResponse = $callback_result;
2168 return $callback_result; // Return the full array
2169 } else {
2170 $this->fallbackResponse = $callback_result;
2171 return true;
2172 }
2173 }
2174 }
2175
2176 return false;
2177 }
2178
2179 /**
2180 * Check if an action is enabled for a specific bot
2181 */
2182 private function is_action_enabled_for_bot($intent, $bot_id) {
2183 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2184 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2185 return true;
2186 }
2187
2188 $enabled_bots = json_decode($intent->enabled_bots, true);
2189
2190 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2191 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2192 return true;
2193 }
2194
2195 // Check if the current bot is in the enabled bots list
2196 return in_array($bot_id, $enabled_bots);
2197 }
2198
2199 // Helper function to clear PDF and Word document related transients
2200 private function clear_pdf_transients($session_id) {
2201 // PDF transients
2202 delete_transient('mxchat_pdf_url_' . $session_id);
2203 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2204 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2205 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2206
2207 // Word document transients
2208 delete_transient('mxchat_word_url_' . $session_id);
2209 delete_transient('mxchat_word_filename_' . $session_id);
2210 delete_transient('mxchat_word_embeddings_' . $session_id);
2211 delete_transient('mxchat_include_word_in_context_' . $session_id);
2212 delete_transient('mxchat_waiting_for_word_' . $session_id);
2213 }
2214
2215
2216
2217 //verified good
2218 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2219 // Get the user's original instruction/message
2220 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2221
2222 // Set instruction for AI - just pass along what the user wanted to say
2223 $this->current_action_instruction = $user_instruction;
2224
2225 // Set the transient to track email capture flow
2226 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2227
2228 // Return false to let the AI generate the response
2229 return false;
2230 }
2231
2232 public function mxchat_generate_image($message, $user_id, $session_id) {
2233 //error_log("Starting image generation for message: " . $message);
2234
2235 // Prepare a prompt for OpenAI image generation
2236 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2237
2238 // Use the existing OpenAI API key
2239 $openai_api_key = sanitize_text_field($this->options['api_key']);
2240
2241 // Call OpenAI GPT Image to generate an image
2242 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2243
2244 // Check if the response contains an image URL
2245 if (isset($image_response['imageUrl'])) {
2246 $image_url = esc_url_raw($image_response['imageUrl']);
2247
2248 // Construct the HTML with a CSS class instead of inline styles
2249 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2250 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2251
2252 // Save the bot message with both text and HTML
2253 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2254 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2255
2256 // Set the fallback response for the chat handler
2257 $this->fallbackResponse = [
2258 'text' => $response_text,
2259 'html' => $response_html,
2260 'images' => [$image_url]
2261 ];
2262
2263 // For debugging/verification - Use json_encode to verify what's being set
2264 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2265
2266 // Return the response directly instead of relying on the property
2267 return $this->fallbackResponse;
2268 } else {
2269 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2270
2271 // Save the error message
2272 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2273
2274 // Set the fallback response for the chat handler
2275 $this->fallbackResponse = [
2276 'text' => $response_text,
2277 'html' => '',
2278 'images' => []
2279 ];
2280
2281 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2282 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2283
2284 // Return the response directly instead of relying on the property
2285 return $this->fallbackResponse;
2286 }
2287 }
2288
2289 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2290 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2291
2292 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2293 if (empty($gemini_api_key)) {
2294 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2295 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2296 return ['text' => $response_text, 'html' => '', 'images' => []];
2297 }
2298
2299 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2300
2301 if (isset($image_response['imageUrl'])) {
2302 $image_url = esc_url_raw($image_response['imageUrl']);
2303
2304 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2305 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2306
2307 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2308 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2309
2310 $this->fallbackResponse = [
2311 'text' => $response_text,
2312 'html' => $response_html,
2313 'images' => [$image_url]
2314 ];
2315
2316 return $this->fallbackResponse;
2317 } else {
2318 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2319
2320 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2321
2322 $this->fallbackResponse = [
2323 'text' => $response_text,
2324 'html' => '',
2325 'images' => []
2326 ];
2327
2328 return $this->fallbackResponse;
2329 }
2330 }
2331
2332 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
2333 $extension = ($mime_type === 'image/jpeg') ? 'jpg' : 'png';
2334 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
2335 $decoded = base64_decode($base64_data);
2336
2337 if ($decoded === false) {
2338 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
2339 }
2340
2341 $upload = wp_upload_bits($filename, null, $decoded);
2342
2343 if (!empty($upload['error'])) {
2344 return new \WP_Error('upload_failed', $upload['error']);
2345 }
2346
2347 $attach_id = wp_insert_attachment([
2348 'post_mime_type' => $mime_type,
2349 'post_title' => $prefix,
2350 'post_content' => '',
2351 'post_status' => 'inherit',
2352 ], $upload['file']);
2353
2354 if (is_wp_error($attach_id)) {
2355 return $attach_id;
2356 }
2357
2358 require_once ABSPATH . 'wp-admin/includes/image.php';
2359 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
2360 wp_update_attachment_metadata($attach_id, $metadata);
2361
2362 return esc_url_raw(wp_get_attachment_url($attach_id));
2363 }
2364
2365 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
2366 $api_url = 'https://api.openai.com/v1/images/generations';
2367 $body = json_encode([
2368 'prompt' => sanitize_text_field($prompt),
2369 'n' => 1,
2370 'size' => '1024x1024',
2371 'quality' => 'medium',
2372 'output_format' => 'png',
2373 'model' => sanitize_text_field($model),
2374 ]);
2375
2376 $args = [
2377 'body' => $body,
2378 'headers' => [
2379 'Content-Type' => 'application/json',
2380 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
2381 ],
2382 'method' => 'POST',
2383 'timeout' => absint($timeout),
2384 ];
2385
2386 $response = wp_remote_post($api_url, $args);
2387
2388 if (is_wp_error($response)) {
2389 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2390 }
2391
2392 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2393
2394 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
2395 if ($b64) {
2396 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
2397 if (is_wp_error($saved_url)) {
2398 return ['error' => $saved_url->get_error_message()];
2399 }
2400 return ['imageUrl' => $saved_url];
2401 } else {
2402 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2403 }
2404 }
2405
2406 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
2407 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
2408
2409 $body = json_encode([
2410 'instances' => [['prompt' => sanitize_text_field($prompt)]],
2411 'parameters' => [
2412 'sampleCount' => 1,
2413 'aspectRatio' => '1:1',
2414 ],
2415 ]);
2416
2417 $args = [
2418 'body' => $body,
2419 'headers' => [
2420 'Content-Type' => 'application/json',
2421 'x-goog-api-key' => sanitize_text_field($api_key),
2422 ],
2423 'method' => 'POST',
2424 'timeout' => absint($timeout),
2425 ];
2426
2427 $response = wp_remote_post($api_url, $args);
2428
2429 if (is_wp_error($response)) {
2430 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2431 }
2432
2433 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2434
2435 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
2436 if ($b64) {
2437 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
2438 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
2439 if (is_wp_error($saved_url)) {
2440 return ['error' => $saved_url->get_error_message()];
2441 }
2442 return ['imageUrl' => $saved_url];
2443 } else {
2444 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2445 }
2446 }
2447
2448 /**
2449 * Handle web search requests.
2450 *
2451 * Sends the refined search query to the Brave Search API and uses the
2452 * results to generate a conversational response with the AI model.
2453 *
2454 * @since 1.0.0
2455 * @param string $message The user's search query.
2456 * @param string $user_id The user identifier.
2457 * @param string $session_id The current session ID.
2458 * @return array Response array containing text with embedded HTML links
2459 */
2460 public function mxchat_handle_search_request($message, $user_id, $session_id) {
2461 // Step 1: Interpret and refine the search query
2462 $refined_search_query = $this->mxchat_interpret_search_query($message);
2463 if (empty($refined_search_query)) {
2464 return array(
2465 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
2466 'html' => ''
2467 );
2468 }
2469
2470 // Retrieve and validate API settings
2471 $options = get_option('mxchat_options');
2472 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2473 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
2474
2475 if (empty($api_key)) {
2476 return array(
2477 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
2478 'html' => ''
2479 );
2480 }
2481
2482 // Build the API request URL
2483 $api_url = add_query_arg(
2484 array(
2485 'q' => rawurlencode($refined_search_query),
2486 'count' => $results_count,
2487 'text_decorations' => 'true',
2488 'rich_data' => 'true',
2489 ),
2490 'https://api.search.brave.com/res/v1/web/search'
2491 );
2492
2493 // Attempt to retrieve cached results first
2494 $transient_key = 'mxchat_search_' . md5($refined_search_query);
2495 $results = get_transient($transient_key);
2496
2497 if (false === $results) {
2498 // SECURITY FIX: Changed to wp_safe_remote_get
2499 $response = wp_safe_remote_get(
2500 $api_url,
2501 array(
2502 'headers' => array(
2503 'Accept' => 'application/json',
2504 'Accept-Encoding' => 'gzip',
2505 'X-Subscription-Token'=> $api_key,
2506 ),
2507 'timeout' => 10,
2508 )
2509 );
2510
2511 if (is_wp_error($response)) {
2512 return array(
2513 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
2514 'html' => ''
2515 );
2516 }
2517
2518 $results = json_decode(wp_remote_retrieve_body($response), true);
2519
2520 if (json_last_error() !== JSON_ERROR_NONE) {
2521 return array(
2522 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
2523 'html' => ''
2524 );
2525 }
2526
2527 // Cache results for one hour
2528 set_transient($transient_key, $results, HOUR_IN_SECONDS);
2529 }
2530
2531 // Process results
2532 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
2533 // Create a more straightforward summary with HTML links
2534 $search_results_text = '';
2535
2536 // Add a simple intro
2537 $search_results_text .= sprintf(
2538 esc_html__("Here's what I found about '%s':", 'mxchat'),
2539 esc_html($refined_search_query)
2540 );
2541
2542 // Add the top results with HTML links
2543 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
2544 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
2545 $url = isset($result['url']) ? esc_url($result['url']) : '';
2546 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
2547
2548 // Add a line break after the intro
2549 $search_results_text .= '<br><br>';
2550
2551 // Add title as a link
2552 $search_results_text .= sprintf(
2553 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
2554 $url,
2555 $title
2556 );
2557
2558 // Add a condensed description
2559 $search_results_text .= sprintf("%s", $description);
2560 }
2561
2562 // Save to chat history
2563 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
2564
2565 // Return the formatted text with embedded HTML links
2566 return array(
2567 'text' => $search_results_text,
2568 'html' => ''
2569 );
2570 } else {
2571 return array(
2572 'text' => sprintf(
2573 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
2574 esc_html($refined_search_query)
2575 ),
2576 'html' => ''
2577 );
2578 }
2579 }
2580
2581 //very good
2582 /**
2583 * Handle image search requests from the chatbot
2584 *
2585 * @param string $message The user's search query
2586 * @param int $user_id The user's ID
2587 * @param string $session_id The chat session ID
2588 * @return array Response array with text and HTML content
2589 */
2590 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
2591 // Step 1: Interpret the search query using the user's selected AI model
2592 $refined_search_query = $this->mxchat_interpret_search_query($message);
2593
2594 // If no query was interpreted, return a fallback message
2595 if (empty($refined_search_query)) {
2596 return array(
2597 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
2598 'html' => "",
2599 );
2600 }
2601
2602 // Brave API URL
2603 $api_url = 'https://api.search.brave.com/res/v1/images/search';
2604
2605 // Retrieve Brave API settings
2606 $options = get_option('mxchat_options');
2607 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2608
2609 if (empty($api_key)) {
2610 return array(
2611 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
2612 'html' => "",
2613 );
2614 }
2615
2616 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2617 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
2618
2619 // Append query parameters based on settings
2620 $api_url = add_query_arg([
2621 'q' => rawurlencode($refined_search_query),
2622 'count' => $image_count,
2623 'safesearch' => $safe_search,
2624 ], $api_url);
2625
2626 // Implement caching
2627 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
2628 $body = get_transient($transient_key);
2629
2630 if (false === $body) {
2631 $args = [
2632 'headers' => [
2633 'Accept' => 'application/json',
2634 'Accept-Encoding' => 'gzip',
2635 'X-Subscription-Token' => $api_key,
2636 ],
2637 'timeout' => 10,
2638 ];
2639
2640 // SECURITY FIX: Changed to wp_safe_remote_get
2641 $response = wp_safe_remote_get($api_url, $args);
2642
2643 if (is_wp_error($response)) {
2644 return array(
2645 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
2646 'html' => "",
2647 );
2648 }
2649
2650 $body = json_decode(wp_remote_retrieve_body($response), true);
2651 set_transient($transient_key, $body, HOUR_IN_SECONDS);
2652 }
2653
2654 // Process the API response
2655 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
2656 $html_output = '<div class="mxchat-image-gallery">';
2657
2658 // Get the configured image count (1-6)
2659 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2660 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
2661
2662 // Use only the requested number of images
2663 for ($i = 0; $i < $display_count; $i++) {
2664 $image = $body['results'][$i];
2665 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
2666 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
2667 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
2668
2669 if ($image_url && $thumbnail_url) {
2670 $html_output .= '<div class="mxchat-image-item">';
2671 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
2672 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
2673 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
2674 $html_output .= '</a></div>';
2675 }
2676 }
2677
2678 $html_output .= '</div>';
2679
2680 // Create response text
2681 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
2682
2683 // Save both response text and HTML to chat history
2684 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2685 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
2686
2687 // Return the combined response
2688 return array(
2689 'text' => $response_text,
2690 'html' => $html_output,
2691 );
2692 } else {
2693 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
2694
2695 // Save the error message to chat history
2696 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2697
2698 return array(
2699 'text' => $response_text,
2700 'html' => "",
2701 );
2702 }
2703 }
2704
2705 /**
2706 * Interpret the search query using the user's selected AI model
2707 *
2708 * @param string $user_query The original query from the user
2709 * @return string The refined search query
2710 */
2711 public function mxchat_interpret_search_query($user_query) {
2712 $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');
2713
2714 // Get options and determine the selected model
2715 $options = $this->options ?? get_option('mxchat_options');
2716 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest';
2717
2718 // Extract model prefix to determine the provider
2719 $model_parts = explode('-', $selected_model);
2720 $provider = strtolower($model_parts[0]);
2721
2722 // Determine which API key to use based on the provider
2723 switch ($provider) {
2724 case 'gemini':
2725 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
2726 if (empty($api_key)) {
2727 return sanitize_text_field($user_query); // Default to original query if API key missing
2728 }
2729 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
2730
2731 case 'claude':
2732 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
2733 if (empty($api_key)) {
2734 return sanitize_text_field($user_query);
2735 }
2736 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
2737
2738 case 'grok':
2739 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
2740 if (empty($api_key)) {
2741 return sanitize_text_field($user_query);
2742 }
2743 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
2744
2745 case 'deepseek':
2746 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
2747 if (empty($api_key)) {
2748 return sanitize_text_field($user_query);
2749 }
2750 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
2751
2752 case 'gpt':
2753 default:
2754 // Default to OpenAI for custom models or unrecognized prefixes
2755 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
2756 if (empty($api_key)) {
2757 return sanitize_text_field($user_query);
2758 }
2759 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
2760 }
2761 }
2762
2763 /**
2764 * Interpret query using OpenAI models
2765 */
2766 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.1-chat-latest') {
2767 $url = 'https://api.openai.com/v1/chat/completions';
2768 $args = [
2769 'headers' => [
2770 'Authorization' => 'Bearer ' . $api_key,
2771 'Content-Type' => 'application/json',
2772 ],
2773 'body' => wp_json_encode([
2774 'model' => $model,
2775 'messages' => [
2776 ['role' => 'system', 'content' => $system_prompt],
2777 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2778 ],
2779 'temperature' => 0.2,
2780 'max_tokens' => 20,
2781 ]),
2782 'method' => 'POST',
2783 'timeout' => 15,
2784 ];
2785
2786 $response = wp_remote_post($url, $args);
2787 if (is_wp_error($response)) {
2788 return sanitize_text_field($user_query);
2789 }
2790
2791 $body = json_decode(wp_remote_retrieve_body($response), true);
2792 return isset($body['choices'][0]['message']['content'])
2793 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
2794 : sanitize_text_field($user_query);
2795 }
2796
2797 /**
2798 * Interpret query using Claude models
2799 */
2800 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
2801 $url = 'https://api.anthropic.com/v1/messages';
2802
2803 $args = [
2804 'headers' => [
2805 'Content-Type' => 'application/json',
2806 'x-api-key' => $api_key,
2807 'anthropic-version' => '2023-06-01',
2808 ],
2809 'body' => wp_json_encode([
2810 'model' => $model,
2811 'system' => $system_prompt,
2812 'messages' => [
2813 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
2814 ],
2815 'max_tokens' => 20,
2816 'temperature' => 0.2,
2817 ]),
2818 'method' => 'POST',
2819 'timeout' => 15,
2820 ];
2821
2822 $response = wp_remote_post($url, $args);
2823 if (is_wp_error($response)) {
2824 return sanitize_text_field($user_query);
2825 }
2826
2827 $body = json_decode(wp_remote_retrieve_body($response), true);
2828 if (!empty($body['content'][0]['text'])) {
2829 return sanitize_text_field(trim($body['content'][0]['text']));
2830 }
2831
2832 return sanitize_text_field($user_query);
2833 }
2834
2835 /**
2836 * Interpret query using Gemini models
2837 */
2838 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
2839 // Use v1beta for preview models, v1 for stable models
2840 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
2841
2842 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
2843
2844 $args = [
2845 'headers' => [
2846 'Content-Type' => 'application/json',
2847 ],
2848 'body' => wp_json_encode([
2849 'contents' => [
2850 [
2851 'role' => 'user',
2852 'parts' => [
2853 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
2854 ]
2855 ]
2856 ],
2857 'generationConfig' => [
2858 'temperature' => 0.2,
2859 'maxOutputTokens' => 20,
2860 ],
2861 ]),
2862 'method' => 'POST',
2863 'timeout' => 15,
2864 ];
2865
2866 $response = wp_remote_post($url, $args);
2867 if (is_wp_error($response)) {
2868 return sanitize_text_field($user_query);
2869 }
2870
2871 $body = json_decode(wp_remote_retrieve_body($response), true);
2872 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
2873 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
2874 }
2875
2876 return sanitize_text_field($user_query);
2877 }
2878
2879 /**
2880 * Interpret query using X.AI (Grok) models
2881 */
2882 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
2883 $url = 'https://api.xai.com/v1/chat/completions';
2884
2885 $args = [
2886 'headers' => [
2887 'Content-Type' => 'application/json',
2888 'Authorization' => 'Bearer ' . $api_key,
2889 ],
2890 'body' => wp_json_encode([
2891 'model' => $model,
2892 'messages' => [
2893 ['role' => 'system', 'content' => $system_prompt],
2894 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2895 ],
2896 'temperature' => 0.2,
2897 'max_tokens' => 20,
2898 ]),
2899 'method' => 'POST',
2900 'timeout' => 15,
2901 ];
2902
2903 $response = wp_remote_post($url, $args);
2904 if (is_wp_error($response)) {
2905 return sanitize_text_field($user_query);
2906 }
2907
2908 $body = json_decode(wp_remote_retrieve_body($response), true);
2909 if (isset($body['choices'][0]['message']['content'])) {
2910 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2911 }
2912
2913 return sanitize_text_field($user_query);
2914 }
2915
2916 /**
2917 * Interpret query using DeepSeek models
2918 */
2919 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
2920 $url = 'https://api.deepseek.com/v1/chat/completions';
2921
2922 $args = [
2923 'headers' => [
2924 'Content-Type' => 'application/json',
2925 'Authorization' => 'Bearer ' . $api_key,
2926 ],
2927 'body' => wp_json_encode([
2928 'model' => $model,
2929 'messages' => [
2930 ['role' => 'system', 'content' => $system_prompt],
2931 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2932 ],
2933 'temperature' => 0.2,
2934 'max_tokens' => 20,
2935 ]),
2936 'method' => 'POST',
2937 'timeout' => 15,
2938 ];
2939
2940 $response = wp_remote_post($url, $args);
2941 if (is_wp_error($response)) {
2942 return sanitize_text_field($user_query);
2943 }
2944
2945 $body = json_decode(wp_remote_retrieve_body($response), true);
2946 if (isset($body['choices'][0]['message']['content'])) {
2947 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2948 }
2949
2950 return sanitize_text_field($user_query);
2951 }
2952
2953 //very good
2954 private function add_email_to_loops($email) {
2955 // Sanitize the email
2956 $email = sanitize_email($email);
2957
2958 // Retrieve and sanitize options
2959 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
2960 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
2961
2962 // Check for missing API key or mailing list ID
2963 if (empty($api_key) || empty($mailing_list_id)) {
2964 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
2965 return;
2966 }
2967
2968 $data = array(
2969 'email' => $email,
2970 'subscribed' => true,
2971 'source' => __('MxChat AI Chatbot', 'mxchat'),
2972 'mailingLists' => array($mailing_list_id => true),
2973 );
2974
2975 $url = 'https://app.loops.so/api/v1/contacts/create';
2976 $args = array(
2977 'body' => wp_json_encode($data),
2978 'headers' => array(
2979 'Authorization' => 'Bearer ' . $api_key,
2980 'Content-Type' => 'application/json',
2981 ),
2982 'method' => 'POST',
2983 'timeout' => 45,
2984 );
2985
2986 $response = wp_remote_post($url, $args);
2987
2988 // Handle errors in the API request
2989 if (is_wp_error($response)) {
2990 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
2991 return;
2992 }
2993
2994 // Check for non-200 HTTP responses
2995 $response_code = wp_remote_retrieve_response_code($response);
2996 if ($response_code != 200) {
2997 $response_body = wp_remote_retrieve_body($response);
2998 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
2999 }
3000 }
3001
3002 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
3003 // Get the maximum number of pages allowed from admin settings
3004 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3005
3006 // Retrieve options for dynamic texts
3007 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
3008 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
3009 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
3010
3011 // Check for explicit request for new PDF
3012 $new_pdf_requested = stripos($message, 'new') !== false ||
3013 stripos($message, 'another') !== false ||
3014 stripos($message, 'different') !== false;
3015
3016 // If user mentions adding/reading a PDF, set waiting flag
3017 if (stripos($message, 'pdf') !== false ||
3018 stripos($message, 'document') !== false ||
3019 stripos($message, 'read') !== false) {
3020 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3021 $this->fallbackResponse['text'] = $trigger_text;
3022 return;
3023 }
3024
3025 // If we're waiting for a URL or user requested new PDF
3026 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3027 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3028 // Process URL... (rest of your existing URL processing code)
3029 } else {
3030 $this->fallbackResponse['text'] = $trigger_text;
3031 }
3032 return;
3033 }
3034
3035 // Default to proceeding with conversation if no specific PDF action is needed
3036 $this->fallbackResponse['text'] = '';
3037 }
3038
3039
3040 /**
3041 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3042 */
3043 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3044 // CLEAR DEBUG LOGGING
3045 //error_log("=== MXCHAT PDF PROCESSING START ===");
3046 //error_log("PDF Source: " . $pdf_source);
3047 //error_log("Max Pages: " . $max_pages);
3048 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3049
3050 // Check if Advanced Claude Toolbar is available and enabled
3051 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3052 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3053
3054 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3055 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3056
3057 if ($claude_available && $claude_enabled) {
3058 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3059
3060 // Attempt Claude processing first
3061 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3062
3063 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
3064 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
3065 //error_log("Claude returned " . count($claude_result) . " processed pages");
3066
3067 // Log first page details for verification
3068 if (isset($claude_result[0])) {
3069 $first_page = $claude_result[0];
3070 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
3071 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
3072 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
3073 }
3074
3075 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
3076 return $claude_result;
3077 } else {
3078 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
3079 //error_log("Claude result type: " . gettype($claude_result));
3080 if (is_array($claude_result)) {
3081 //error_log("Claude result count: " . count($claude_result));
3082 }
3083 }
3084 }
3085
3086 // Fallback to basic processing
3087 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
3088
3089 $upload_dir = wp_upload_dir();
3090 $temp_file = null;
3091
3092 try {
3093 // Your existing basic processing code here...
3094 // (I'll include the key parts with debug logging)
3095
3096 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
3097 //error_log("Downloading PDF from URL...");
3098
3099 // SECURITY FIX: Validate URL before processing
3100 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
3101 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
3102 return false;
3103 }
3104
3105 $temp_file = wp_tempnam($pdf_source);
3106
3107 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
3108 $response = wp_safe_remote_get($pdf_source, [
3109 'timeout' => 60,
3110 'headers' => ['User-Agent' => 'MxChat PDF Processor']
3111 ]);
3112
3113 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
3114 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
3115 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
3116 return false;
3117 }
3118
3119 global $wp_filesystem;
3120 if (empty($wp_filesystem)) {
3121 require_once ABSPATH . 'wp-admin/includes/file.php';
3122 WP_Filesystem();
3123 }
3124 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
3125 //error_log("✅ PDF downloaded successfully");
3126 } else {
3127 $temp_file = $pdf_source;
3128 //error_log("Using local PDF file: " . $temp_file);
3129 }
3130
3131 // Parse PDF
3132 //error_log("Parsing PDF with basic parser...");
3133 mxchat_load_pdf_parser();
3134 $parser = new \Smalot\PdfParser\Parser();
3135 $pdf = $parser->parseFile($temp_file);
3136 $pages = $pdf->getPages();
3137
3138 //error_log("PDF contains " . count($pages) . " pages");
3139
3140 if (count($pages) > $max_pages) {
3141 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
3142 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
3143 unlink($temp_file);
3144 }
3145 return 'too_many_pages';
3146 }
3147
3148 $embeddings = [];
3149 $processed_pages = 0;
3150
3151 foreach ($pages as $page_number => $page) {
3152 $text = $page->getText();
3153
3154 if (empty(trim($text))) {
3155 //error_log("Skipping empty page: " . ($page_number + 1));
3156 continue;
3157 }
3158
3159 $text = $this->mxchat_clean_text($text);
3160
3161 $embedding = $this->mxchat_generate_embedding(
3162 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
3163 $this->options['api_key']
3164 );
3165
3166 if ($embedding) {
3167 $embeddings[] = [
3168 'page_number' => $page_number + 1,
3169 'embedding' => $embedding,
3170 'text' => $text,
3171 'enhanced' => false, // CLEARLY MARK AS BASIC
3172 'processing_method' => 'basic_pdf_parser'
3173 ];
3174 $processed_pages++;
3175 }
3176 }
3177
3178 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
3179
3180 // Cleanup
3181 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3182 unlink($temp_file);
3183 }
3184
3185 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
3186 return $embeddings;
3187
3188 } catch (\Exception $e) {
3189 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
3190 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3191 unlink($temp_file);
3192 }
3193 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
3194 return false;
3195 }
3196 }
3197
3198
3199 /**
3200 * Validate PDF URL for security
3201 * Prevents SSRF attacks by blocking dangerous URLs
3202 */
3203
3204 private function mxchat_is_safe_pdf_url($url) {
3205 // Use WordPress core function for comprehensive validation
3206 // This blocks localhost, private IPs, and reserved IP ranges
3207 $validated_url = wp_http_validate_url($url);
3208
3209 if ($validated_url === false) {
3210 return false;
3211 }
3212
3213 // Additional check: only allow HTTP/HTTPS schemes
3214 $parsed = parse_url($url);
3215 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
3216 return false;
3217 }
3218
3219 return true;
3220 }
3221
3222
3223 private function mxchat_clean_text($text) {
3224 // Remove excessive whitespace
3225 $text = preg_replace('/\s+/', ' ', $text);
3226
3227 // Remove control characters except newlines and tabs
3228 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
3229
3230 // Normalize line endings
3231 $text = str_replace(["\r\n", "\r"], "\n", $text);
3232
3233 // Trim whitespace
3234 $text = trim($text);
3235
3236 return $text;
3237 }
3238
3239 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
3240 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
3241
3242 $most_relevant = null;
3243 $highest_similarity = -INF;
3244
3245 foreach ($embeddings as $page_data) {
3246 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
3247
3248 if ($similarity > $highest_similarity) {
3249 $highest_similarity = $similarity;
3250 $most_relevant = $page_data['page_number'];
3251 }
3252 }
3253
3254 if (!is_null($most_relevant)) {
3255 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
3256 return array_filter($embeddings, function ($page) use ($page_numbers) {
3257 return in_array($page['page_number'], $page_numbers);
3258 });
3259 }
3260
3261 return [];
3262 }
3263
3264
3265 public function handle_pdf_upload() {
3266 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3267
3268 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
3269 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
3270 return;
3271 }
3272
3273 // SECURITY FIX: Check if PDF uploads are enabled in settings
3274 $options = get_option('mxchat_options', array());
3275 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
3276
3277 if ($show_pdf_button !== 'on') {
3278 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
3279 return;
3280 }
3281
3282 $file = $_FILES['pdf_file'];
3283 $session_id = sanitize_text_field($_POST['session_id']);
3284 $original_filename = sanitize_text_field($file['name']);
3285
3286 // SECURITY FIX: Verify session ownership before allowing upload
3287 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
3288 $session_owner = get_option("mxchat_session_owner_{$session_id}");
3289
3290 if ($session_owner && $session_owner !== $current_user_identifier) {
3291 wp_send_json_error(esc_html__('Unauthorized access.', 'mxchat'));
3292 return;
3293 }
3294
3295 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
3296 if ($file_type['type'] !== 'application/pdf') {
3297 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
3298 return;
3299 }
3300
3301 $upload_dir = wp_upload_dir();
3302
3303 // SECURITY FIX: Generate random filename without exposing session_id
3304 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
3305 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
3306 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
3307
3308 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
3309 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
3310 return;
3311 }
3312
3313 $this->clear_pdf_transients($session_id);
3314
3315 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3316 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
3317
3318 if ($embeddings === 'too_many_pages') {
3319 unlink($pdf_path);
3320 $error_message = sprintf(
3321 $this->options['pdf_intent_error_text'] ??
3322 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
3323 $max_pages
3324 );
3325 wp_send_json_error($error_message);
3326 return;
3327 }
3328
3329 if ($embeddings === false || empty($embeddings)) {
3330 unlink($pdf_path);
3331 $error_message = $this->options['pdf_intent_error_text'] ??
3332 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
3333 wp_send_json_error($error_message);
3334 return;
3335 }
3336
3337 if (!empty($embeddings)) {
3338 // Store the mapping between session and the random filename
3339 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
3340 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
3341 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
3342 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
3343
3344 $success_message = $this->options['pdf_intent_success_text'] ??
3345 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
3346
3347 wp_send_json_success([
3348 'message' => $success_message,
3349 'filename' => $original_filename
3350 ]);
3351 return;
3352 }
3353
3354 unlink($pdf_path);
3355 $error_message = $this->options['pdf_intent_error_text'] ??
3356 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
3357 wp_send_json_error($error_message);
3358 return;
3359 }
3360 public function handle_pdf_remove() {
3361 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3362
3363 if (empty($_POST['session_id'])) {
3364 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
3365 wp_die();
3366 }
3367
3368 $session_id = sanitize_text_field($_POST['session_id']);
3369 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
3370
3371 if ($pdf_path && file_exists($pdf_path)) {
3372 unlink($pdf_path);
3373 }
3374
3375 $this->clear_pdf_transients($session_id);
3376
3377 wp_send_json_success([
3378 'message' => esc_html__('PDF removed successfully.', 'mxchat')
3379 ]);
3380 wp_die();
3381 }
3382
3383
3384 function mxchat_fetch_new_messages() {
3385 $session_id = sanitize_text_field($_POST['session_id']);
3386 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
3387 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
3388 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
3389
3390 if (empty($session_id)) {
3391 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
3392 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
3393 wp_die();
3394 }
3395
3396 $history = get_option("mxchat_history_{$session_id}", []);
3397
3398 //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
3399 //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
3400 //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
3401 //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
3402
3403 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
3404 //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
3405
3406 // If persistence is enabled, show all new messages
3407 if ($persistence_enabled) {
3408 $has_id = !empty($message['id']);
3409 $is_agent = $message['role'] === 'agent';
3410
3411 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
3412 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
3413 $is_newer = true;
3414 } else {
3415 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
3416 }
3417
3418 //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
3419
3420 return $has_id && $is_newer && $is_agent;
3421 }
3422
3423 // If persistence is disabled, only show messages after initial timestamp
3424 return !empty($message['id']) &&
3425 $message['role'] === 'agent' &&
3426 $message['timestamp'] > $initial_timestamp;
3427 });
3428
3429 //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
3430
3431 // Include current chat mode so frontend can detect agent→AI transitions
3432 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
3433
3434 wp_send_json_success([
3435 'new_messages' => array_values($new_messages),
3436 'chat_mode' => $chat_mode
3437 ]);
3438 wp_die();
3439 }
3440 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
3441 // First check if live agents are available
3442 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
3443 if ($live_agent_available !== 'on') {
3444 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3445 $this->fallbackResponse = [
3446 'text' => $away_message,
3447 'html' => '',
3448 'images' => [],
3449 'chat_mode' => 'ai'
3450 ];
3451 wp_send_json([
3452 'text' => $away_message,
3453 'html' => '',
3454 'chat_mode' => 'ai',
3455 'session_id' => $session_id
3456 ]);
3457 wp_die();
3458 }
3459
3460 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3461
3462 if (empty($slack_bot_token)) {
3463 return false;
3464 }
3465
3466 // Check if channel already exists for this session
3467 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3468
3469 if (empty($channel_id)) {
3470 // Create new channel with session ID as name
3471 $channel_name = $this->generate_channel_name($session_id);
3472
3473 //error_log("Attempting to create channel: $channel_name");
3474
3475 $response = wp_remote_post('https://slack.com/api/conversations.create', [
3476 'headers' => [
3477 'Content-Type' => 'application/json',
3478 'Authorization' => 'Bearer ' . $slack_bot_token
3479 ],
3480 'body' => json_encode([
3481 'name' => $channel_name,
3482 'is_private' => false // Public channel - anyone in workspace can join
3483 ])
3484 ]);
3485
3486 if (!is_wp_error($response)) {
3487 $response_body = wp_remote_retrieve_body($response);
3488 $response_data = json_decode($response_body, true);
3489
3490 //error_log("Channel creation response: " . $response_body);
3491
3492 if (isset($response_data['ok']) && $response_data['ok']) {
3493 $channel_id = $response_data['channel']['id'];
3494 $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
3495 //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
3496 update_option("mxchat_channel_{$session_id}", $channel_id);
3497
3498 // Auto-invite agents to the channel
3499 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
3500
3501 if (!empty($agent_user_ids)) {
3502 // Parse user IDs (one per line)
3503 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
3504
3505 foreach ($user_ids as $user_id_to_invite) {
3506 //error_log("Inviting user to channel: $user_id_to_invite");
3507
3508 $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
3509 'headers' => [
3510 'Content-Type' => 'application/json',
3511 'Authorization' => 'Bearer ' . $slack_bot_token
3512 ],
3513 'body' => json_encode([
3514 'channel' => $channel_id,
3515 'users' => $user_id_to_invite
3516 ])
3517 ]);
3518
3519 if (!is_wp_error($invite_response)) {
3520 $invite_body = wp_remote_retrieve_body($invite_response);
3521 $invite_data = json_decode($invite_body, true);
3522 //error_log("Invite response for $user_id_to_invite: " . $invite_body);
3523
3524 if (isset($invite_data['ok']) && $invite_data['ok']) {
3525 //error_log("Successfully invited user $user_id_to_invite to channel");
3526 } else {
3527 //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
3528 }
3529 } else {
3530 //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
3531 }
3532 }
3533 } else {
3534 //error_log("No agent user IDs configured for auto-invite");
3535 }
3536 } else {
3537 //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
3538 }
3539 } else {
3540 //error_log("WP Error creating channel: " . $response->get_error_message());
3541 }
3542
3543 if (empty($channel_id)) {
3544 return false; // Failed to create channel
3545 }
3546 }
3547
3548 // Get recent chat history
3549 $history = get_option("mxchat_history_{$session_id}", []);
3550 $recent_history = array_slice($history, -5);
3551
3552 // Format conversation context
3553 $conversation_context = "";
3554 if (!empty($recent_history)) {
3555 $conversation_context = "*Recent Conversation:*\n";
3556 foreach ($recent_history as $hist_message) {
3557 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
3558 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
3559 }
3560 $conversation_context .= "\n";
3561 }
3562
3563 update_option("mxchat_mode_{$session_id}", 'agent');
3564
3565 // Send message to channel
3566 $channel_message = "🔔 *New Live Agent Request*\n\n";
3567 $channel_message .= "*Session ID:* `{$session_id}`\n";
3568 $channel_message .= "*User ID:* `{$user_id}`\n\n";
3569
3570 if (!empty($conversation_context)) {
3571 $channel_message .= $conversation_context;
3572 }
3573
3574 $channel_message .= "*Current Message:*\n{$message}\n\n";
3575 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
3576
3577 wp_remote_post('https://slack.com/api/chat.postMessage', [
3578 'headers' => [
3579 'Content-Type' => 'application/json',
3580 'Authorization' => 'Bearer ' . $slack_bot_token
3581 ],
3582 'body' => json_encode([
3583 'channel' => $channel_id,
3584 'text' => $channel_message,
3585 'mrkdwn' => true
3586 ])
3587 ]);
3588
3589 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
3590 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3591
3592 $this->fallbackResponse = [
3593 'text' => $success_message,
3594 'html' => '',
3595 'images' => [],
3596 'chat_mode' => 'agent'
3597 ];
3598
3599 wp_send_json([
3600 'success' => true,
3601 'text' => $success_message,
3602 'html' => '',
3603 'chat_mode' => 'agent',
3604 'session_id' => $session_id,
3605 'fallbackResponse' => $this->fallbackResponse
3606 ]);
3607 wp_die();
3608 }
3609
3610 private function generate_channel_name($session_id) {
3611 $email = null;
3612 $name = null;
3613
3614 // 1. First priority: Check if user is logged in and get their info
3615 if (is_user_logged_in()) {
3616 $current_user = wp_get_current_user();
3617 if (!empty($current_user->user_email)) {
3618 $email = $current_user->user_email;
3619 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
3620 }
3621 if (!empty($current_user->display_name)) {
3622 $name = $current_user->display_name;
3623 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
3624 }
3625 }
3626
3627 // 2. Second priority: Check for saved email/name from "require email to chat" option
3628 if (empty($email)) {
3629 $email_option_key = "mxchat_email_{$session_id}";
3630 $saved_email = get_option($email_option_key);
3631 if (!empty($saved_email)) {
3632 $email = $saved_email;
3633 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
3634 }
3635 }
3636
3637 if (empty($name)) {
3638 $name_option_key = "mxchat_name_{$session_id}";
3639 $saved_name = get_option($name_option_key);
3640 if (!empty($saved_name)) {
3641 $name = $saved_name;
3642 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
3643 }
3644 }
3645
3646 // 3. Third priority: Check existing chat transcript for email/name
3647 if (empty($email) || empty($name)) {
3648 global $wpdb;
3649 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3650 $existing_data = $wpdb->get_row($wpdb->prepare(
3651 "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",
3652 $session_id
3653 ));
3654
3655 if ($existing_data) {
3656 if (empty($email) && !empty($existing_data->user_email)) {
3657 $email = $existing_data->user_email;
3658 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
3659 }
3660 if (empty($name) && !empty($existing_data->user_name)) {
3661 $name = $existing_data->user_name;
3662 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
3663 }
3664 }
3665 }
3666
3667 // 4. Generate channel name based on priority: Name > Email > Session ID
3668 $channel_name = '';
3669
3670 if (!empty($name)) {
3671 // Convert name to valid Slack channel name
3672 $base_name = strtolower(trim($name));
3673 // Replace spaces and invalid characters
3674 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
3675 $base_name = preg_replace('/\s+/', '-', $base_name);
3676 $base_name = trim($base_name, '-');
3677
3678 // Get last 4 characters of session ID for uniqueness
3679 $session_suffix = substr($session_id, -4);
3680 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
3681
3682 // Slack channel names have a 21 character limit
3683 if (strlen($channel_name) > 21) {
3684 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
3685 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
3686 $truncated_name = substr($base_name, 0, $available_space);
3687 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
3688 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
3689 }
3690
3691 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
3692
3693 } elseif (!empty($email)) {
3694 // Convert email to valid Slack channel name (your existing logic)
3695 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
3696 // Remove any remaining invalid characters
3697 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
3698 // Ensure it doesn't end with a hyphen
3699 $channel_name = rtrim($channel_name, '-');
3700 // Slack channel names have a 21 character limit, so truncate if needed
3701 if (strlen($channel_name) > 21) {
3702 $channel_name = substr($channel_name, 0, 21);
3703 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
3704 }
3705
3706 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
3707
3708 } else {
3709 // Fallback to session ID if no name or email found
3710 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
3711 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
3712 }
3713
3714 // Final validation - ensure channel name meets Slack requirements
3715 if (strlen($channel_name) > 21) {
3716 $channel_name = substr($channel_name, 0, 21);
3717 $channel_name = rtrim($channel_name, '-');
3718 }
3719
3720 //error_log("[DEBUG] Generated channel name: {$channel_name}");
3721 return $channel_name;
3722 }
3723
3724 /**
3725 * Telegram Live Agent Handover
3726 * Creates a forum topic in the Telegram group and notifies agents
3727 */
3728 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
3729 // Check if Telegram agents are available
3730 $telegram_available = $this->options['telegram_status'] ?? 'off';
3731 if ($telegram_available !== 'on') {
3732 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3733 $this->fallbackResponse = [
3734 'text' => $away_message,
3735 'html' => '',
3736 'images' => [],
3737 'chat_mode' => 'ai'
3738 ];
3739 wp_send_json([
3740 'text' => $away_message,
3741 'html' => '',
3742 'chat_mode' => 'ai',
3743 'session_id' => $session_id
3744 ]);
3745 wp_die();
3746 }
3747
3748 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3749 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
3750
3751 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
3752 return false;
3753 }
3754
3755 // Check if topic already exists for this session
3756 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3757
3758 if (empty($topic_id)) {
3759 // Generate topic name
3760 $topic_name = $this->generate_telegram_topic_name($session_id);
3761
3762 // Random icon color (Telegram forum topic colors)
3763 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
3764 $icon_color = $icon_colors[array_rand($icon_colors)];
3765
3766 // Create forum topic
3767 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
3768 'headers' => ['Content-Type' => 'application/json'],
3769 'body' => json_encode([
3770 'chat_id' => $telegram_group_id,
3771 'name' => $topic_name,
3772 'icon_color' => $icon_color
3773 ])
3774 ]);
3775
3776 if (!is_wp_error($response)) {
3777 $response_body = wp_remote_retrieve_body($response);
3778 $response_data = json_decode($response_body, true);
3779
3780 if (isset($response_data['ok']) && $response_data['ok']) {
3781 $topic_id = $response_data['result']['message_thread_id'];
3782 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
3783 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
3784 }
3785 }
3786
3787 if (empty($topic_id)) {
3788 return false; // Failed to create topic
3789 }
3790 }
3791
3792 // Get recent chat history
3793 $history = get_option("mxchat_history_{$session_id}", []);
3794 $recent_history = array_slice($history, -5);
3795
3796 // Format conversation context for Telegram (HTML format)
3797 $conversation_context = "";
3798 if (!empty($recent_history)) {
3799 $conversation_context = "<b>Recent Conversation:</b>\n";
3800 foreach ($recent_history as $hist_message) {
3801 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
3802 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
3803 $conversation_context .= "{$role_display}: {$escaped_content}\n";
3804 }
3805 $conversation_context .= "\n";
3806 }
3807
3808 // Get user info
3809 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
3810 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
3811
3812 // Update session mode
3813 update_option("mxchat_mode_{$session_id}", 'agent');
3814
3815 // Send initial message to topic
3816 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3817 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
3818 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
3819 $topic_message .= "<b>User:</b> {$user_name}\n";
3820 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
3821
3822 if (!empty($conversation_context)) {
3823 $topic_message .= $conversation_context;
3824 }
3825
3826 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
3827 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
3828 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
3829
3830 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3831 'headers' => ['Content-Type' => 'application/json'],
3832 'body' => json_encode([
3833 'chat_id' => $telegram_group_id,
3834 'message_thread_id' => $topic_id,
3835 'text' => $topic_message,
3836 'parse_mode' => 'HTML'
3837 ])
3838 ]);
3839
3840 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
3841 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3842
3843 $this->fallbackResponse = [
3844 'text' => $success_message,
3845 'html' => '',
3846 'images' => [],
3847 'chat_mode' => 'agent'
3848 ];
3849
3850 wp_send_json([
3851 'success' => true,
3852 'text' => $success_message,
3853 'html' => '',
3854 'chat_mode' => 'agent',
3855 'session_id' => $session_id,
3856 'fallbackResponse' => $this->fallbackResponse
3857 ]);
3858 wp_die();
3859 }
3860
3861 /**
3862 * Generate topic name for Telegram forum
3863 */
3864 private function generate_telegram_topic_name($session_id) {
3865 $name = null;
3866 $email = null;
3867
3868 // Check logged in user
3869 if (is_user_logged_in()) {
3870 $current_user = wp_get_current_user();
3871 if (!empty($current_user->display_name)) {
3872 $name = $current_user->display_name;
3873 }
3874 if (!empty($current_user->user_email)) {
3875 $email = $current_user->user_email;
3876 }
3877 }
3878
3879 // Check session data
3880 if (empty($name)) {
3881 $name = get_option("mxchat_name_{$session_id}");
3882 }
3883 if (empty($email)) {
3884 $email = get_option("mxchat_email_{$session_id}");
3885 }
3886
3887 // Generate topic name
3888 $session_suffix = substr($session_id, -6);
3889
3890 if (!empty($name)) {
3891 // Clean name for topic (max 128 chars in Telegram)
3892 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
3893 $clean_name = trim($clean_name);
3894 if (strlen($clean_name) > 50) {
3895 $clean_name = substr($clean_name, 0, 50);
3896 }
3897 return "Chat - {$clean_name} ({$session_suffix})";
3898 } elseif (!empty($email)) {
3899 // Use email prefix
3900 $email_prefix = explode('@', $email)[0];
3901 if (strlen($email_prefix) > 30) {
3902 $email_prefix = substr($email_prefix, 0, 30);
3903 }
3904 return "Chat - {$email_prefix} ({$session_suffix})";
3905 }
3906
3907 return "Chat - {$session_suffix}";
3908 }
3909
3910 /**
3911 * Send user message to Telegram agent
3912 */
3913 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
3914 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3915 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3916 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3917
3918 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
3919 return false;
3920 }
3921
3922 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3923 $user_message = "👤 <b>User:</b> {$escaped_message}";
3924
3925 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3926 'headers' => ['Content-Type' => 'application/json'],
3927 'body' => json_encode([
3928 'chat_id' => $group_id,
3929 'message_thread_id' => $topic_id,
3930 'text' => $user_message,
3931 'parse_mode' => 'HTML'
3932 ])
3933 ]);
3934
3935 return !is_wp_error($response);
3936 }
3937
3938 /**
3939 * Handle incoming Telegram webhook
3940 */
3941 public function handle_telegram_webhook(WP_REST_Request $request) {
3942 $body = $request->get_body();
3943 $data = json_decode($body, true);
3944
3945 //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
3946
3947 // Handle message events from forum topics
3948 if (isset($data['message'])) {
3949 $message_data = $data['message'];
3950
3951 // Skip if not from a forum topic
3952 if (!isset($message_data['message_thread_id'])) {
3953 //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
3954 return new WP_REST_Response(['ok' => true]);
3955 }
3956
3957 // Skip bot messages
3958 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
3959 //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
3960 return new WP_REST_Response(['ok' => true]);
3961 }
3962
3963 $chat_id = $message_data['chat']['id'] ?? '';
3964 $topic_id = $message_data['message_thread_id'];
3965 $message_text = $message_data['text'] ?? '';
3966 $message_id = $message_data['message_id'] ?? '';
3967 $from = $message_data['from'] ?? [];
3968 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
3969 if (empty($agent_name)) {
3970 $agent_name = $from['username'] ?? 'Agent';
3971 }
3972
3973 //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
3974
3975 // Skip empty messages
3976 if (empty($message_text)) {
3977 //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
3978 return new WP_REST_Response(['ok' => true]);
3979 }
3980
3981 // Find session ID by topic ID - cast to string for comparison
3982 global $wpdb;
3983 $topic_id_str = strval($topic_id);
3984 $session_option = $wpdb->get_var(
3985 $wpdb->prepare(
3986 "SELECT option_name FROM {$wpdb->options}
3987 WHERE option_name LIKE %s
3988 AND option_value = %s",
3989 'mxchat_telegram_topic_%',
3990 $topic_id_str
3991 )
3992 );
3993
3994 //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
3995
3996 if ($session_option) {
3997 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
3998 //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
3999
4000 // Verify the group ID matches
4001 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
4002 //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
4003
4004 if (strval($stored_group_id) != strval($chat_id)) {
4005 //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
4006 return new WP_REST_Response(['ok' => true]);
4007 }
4008
4009 // Check for closure commands
4010 $lower_text = strtolower(trim($message_text));
4011 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
4012 //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
4013 // End the live agent session
4014 update_option("mxchat_mode_{$session_id}", 'ai');
4015
4016 // Save disconnect message
4017 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
4018 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
4019
4020 // Notify in Telegram
4021 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4022 if (!empty($telegram_bot_token)) {
4023 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4024 'headers' => ['Content-Type' => 'application/json'],
4025 'body' => json_encode([
4026 'chat_id' => $chat_id,
4027 'message_thread_id' => $topic_id,
4028 'text' => "✅ Session closed. User returned to AI chatbot.",
4029 'parse_mode' => 'HTML'
4030 ])
4031 ]);
4032
4033 // Optionally close the topic
4034 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
4035 'headers' => ['Content-Type' => 'application/json'],
4036 'body' => json_encode([
4037 'chat_id' => $chat_id,
4038 'message_thread_id' => $topic_id
4039 ])
4040 ]);
4041 }
4042
4043 return new WP_REST_Response(['ok' => true]);
4044 }
4045
4046 // Deduplicate messages
4047 $message_key = md5($session_id . $message_id . $message_text);
4048 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
4049
4050 if (in_array($message_key, $processed_messages)) {
4051 //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
4052 return new WP_REST_Response(['ok' => true]);
4053 }
4054
4055 $processed_messages[] = $message_key;
4056 if (count($processed_messages) > 50) {
4057 $processed_messages = array_slice($processed_messages, -50);
4058 }
4059 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4060
4061 // Save the agent message - format with agent name prefix for proper parsing
4062 $formatted_message = "Agent: {$agent_name} - {$message_text}";
4063 //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
4064
4065 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
4066
4067 // Verify the message was saved to history
4068 $history = get_option("mxchat_history_{$session_id}", []);
4069 $last_message = end($history);
4070 //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
4071
4072 // Send confirmation back to Telegram
4073 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4074 if (!empty($telegram_bot_token)) {
4075 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
4076 if (!get_transient($confirm_key)) {
4077 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4078 'headers' => ['Content-Type' => 'application/json'],
4079 'body' => json_encode([
4080 'chat_id' => $chat_id,
4081 'message_thread_id' => $topic_id,
4082 'text' => "✅ <i>Message sent to user</i>",
4083 'parse_mode' => 'HTML',
4084 'reply_to_message_id' => $message_id
4085 ])
4086 ]);
4087 set_transient($confirm_key, true, 300);
4088 }
4089 }
4090 } else {
4091 //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
4092 }
4093 } else {
4094 //error_log('[MxChat Telegram DEBUG] No message in webhook data');
4095 }
4096
4097 return new WP_REST_Response(['ok' => true]);
4098 }
4099
4100 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
4101 // Check if this is a Telegram agent session
4102 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4103 if (!empty($telegram_topic_id)) {
4104 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
4105 }
4106
4107 // Otherwise, try Slack
4108 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4109 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4110
4111 if (empty($slack_bot_token) || empty($channel_id)) {
4112 return false;
4113 }
4114
4115 $user_message = "💬 *User:* {$message}";
4116
4117 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4118 'headers' => [
4119 'Content-Type' => 'application/json',
4120 'Authorization' => 'Bearer ' . $slack_bot_token
4121 ],
4122 'body' => json_encode([
4123 'channel' => $channel_id,
4124 'text' => $user_message,
4125 'mrkdwn' => true
4126 ])
4127 ]);
4128
4129 return !is_wp_error($response);
4130 }
4131 public function handle_slack_interaction(WP_REST_Request $request) {
4132 //error_log('Received Slack interaction');
4133
4134 $payload = json_decode($request->get_param('payload'), true);
4135 //error_log('Payload: ' . print_r($payload, true));
4136
4137 // Handle button click
4138 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
4139 $session_id = $payload['actions'][0]['value'];
4140 $trigger_id = $payload['trigger_id'];
4141
4142 // Get Bot Token from settings
4143 $slack_token = $this->options['live_agent_bot_token'] ?? '';
4144
4145 if (empty($slack_token)) {
4146 //error_log('Slack Bot Token not configured');
4147 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
4148 }
4149 $response = wp_remote_post('https://slack.com/api/views.open', [
4150 'headers' => [
4151 'Content-Type' => 'application/json',
4152 'Authorization' => 'Bearer ' . $slack_token
4153 ],
4154 'body' => json_encode([
4155 'trigger_id' => $trigger_id,
4156 'view' => [
4157 'type' => 'modal',
4158 'callback_id' => 'reply_modal',
4159 'title' => [
4160 'type' => 'plain_text',
4161 'text' => __('Reply to User', 'mxchat')
4162 ],
4163 'submit' => [
4164 'type' => 'plain_text',
4165 'text' => __('Send', 'mxchat')
4166 ],
4167 'close' => [
4168 'type' => 'plain_text',
4169 'text' => __('Cancel', 'mxchat')
4170 ],
4171 'blocks' => [
4172 [
4173 'type' => 'input',
4174 'block_id' => 'reply_block',
4175 'label' => [
4176 'type' => 'plain_text',
4177 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
4178 ],
4179 'element' => [
4180 'type' => 'plain_text_input',
4181 'action_id' => 'message',
4182 'multiline' => true,
4183 'placeholder' => [
4184 'type' => 'plain_text',
4185 'text' => __('Type your message here...', 'mxchat')
4186 ]
4187 ]
4188 ]
4189 ],
4190 'private_metadata' => $session_id
4191 ]
4192 ])
4193 ]);
4194
4195 //error_log('Views.open response: ' . print_r($response, true));
4196
4197 // Return immediate acknowledgment
4198 return new WP_REST_Response(['ok' => true]);
4199 }
4200
4201 // Handle modal submission
4202 // Handle modal submission
4203 if ($payload['type'] === 'view_submission') {
4204 $session_id = $payload['view']['private_metadata'];
4205 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
4206
4207 // Save the message (keep the message_id but don't include in response)
4208 $this->mxchat_save_chat_message($session_id, 'agent', $message);
4209
4210 // Keep the original response format for Slack
4211 return new WP_REST_Response([
4212 'response_action' => 'clear'
4213 ]);
4214 }
4215
4216 // Default acknowledgment
4217 return new WP_REST_Response(['ok' => true]);
4218 }
4219 public function mxchat_handle_agent_response(WP_REST_Request $request) {
4220 //error_log('Received agent response request');
4221 //error_log('Request data: ' . print_r($request->get_params(), true));
4222 // //error_log('Raw body: ' . file_get_contents('php://input'));
4223
4224 // Get the data from Slack's slash command format
4225 $command_text = $request->get_param('text');
4226 // //error_log('Command text: ' . $command_text);
4227
4228 if (empty($command_text)) {
4229 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
4230 return new WP_REST_Response([
4231 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
4232 ], 400);
4233 }
4234
4235 // Split the command text into session_id and message
4236 $parts = explode(' ', $command_text, 2);
4237 if (count($parts) !== 2) {
4238 //error_log('Agent response error: Invalid command format');
4239 return new WP_REST_Response([
4240 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
4241 ], 400);
4242 }
4243
4244 $session_id = sanitize_text_field($parts[0]);
4245 $message = sanitize_text_field($parts[1]);
4246
4247 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
4248
4249 // Save the message
4250 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
4251
4252 if (!$message_id) {
4253 // //error_log('Failed to save agent message');
4254 return new WP_REST_Response([
4255 'error' => esc_html__('Failed to save message', 'mxchat')
4256 ], 500);
4257 }
4258
4259 // Return success response in Slack's expected format
4260 return new WP_REST_Response([
4261 'response_type' => 'in_channel',
4262 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
4263 ], 200);
4264 }
4265 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
4266 // Update mode to AI
4267 update_option("mxchat_mode_{$session_id}", 'ai');
4268
4269 // Clear any existing PDF context to start fresh
4270 $this->clear_pdf_transients($session_id);
4271
4272 // Set the response with explicit chat_mode
4273 $this->fallbackResponse = [
4274 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
4275 'html' => '',
4276 'images' => [],
4277 'chat_mode' => 'ai' // Ensure this is set
4278 ];
4279
4280 // Return the complete response array instead of just true
4281 return $this->fallbackResponse;
4282 }
4283
4284 public function handle_slack_messages(WP_REST_Request $request) {
4285 // Log the incoming request for debugging
4286 //error_log('Slack events request received: ' . $request->get_body());
4287
4288 $body = $request->get_body();
4289 $data = json_decode($body, true);
4290
4291 // Handle Slack URL verification
4292 if (isset($data['type']) && $data['type'] === 'url_verification') {
4293 //error_log('Slack URL verification challenge: ' . $data['challenge']);
4294 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
4295 }
4296
4297 // IMPORTANT: Handle Slack's event deduplication
4298 if (isset($data['event_id'])) {
4299 $event_id = $data['event_id'];
4300 $processed_events = get_transient('mxchat_slack_events') ?: [];
4301
4302 // Check if we've already processed this event
4303 if (in_array($event_id, $processed_events)) {
4304 //error_log("Duplicate event detected: $event_id");
4305 return new WP_REST_Response(['ok' => true]);
4306 }
4307
4308 // Add this event to processed list
4309 $processed_events[] = $event_id;
4310 // Keep only last 100 events to prevent memory issues
4311 if (count($processed_events) > 100) {
4312 $processed_events = array_slice($processed_events, -100);
4313 }
4314 // Store for 1 hour
4315 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
4316 }
4317
4318 // Handle message events
4319 if (isset($data['event']) && $data['event']['type'] === 'message') {
4320 $event = $data['event'];
4321
4322 // Skip bot messages and messages with subtypes (like bot_message)
4323 if (isset($event['bot_id']) || isset($event['subtype'])) {
4324 return new WP_REST_Response(['ok' => true]);
4325 }
4326
4327 // Additional check: Skip if this is a threaded reply to our confirmation
4328 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
4329 return new WP_REST_Response(['ok' => true]);
4330 }
4331
4332 $channel_id = $event['channel'];
4333 $message_text = $event['text'] ?? '';
4334 $message_ts = $event['ts'] ?? '';
4335
4336 // Find session ID by looking for matching channel
4337 global $wpdb;
4338 $session_option = $wpdb->get_var(
4339 $wpdb->prepare(
4340 "SELECT option_name FROM {$wpdb->options}
4341 WHERE option_name LIKE 'mxchat_channel_%'
4342 AND option_value = %s",
4343 $channel_id
4344 )
4345 );
4346
4347 if ($session_option) {
4348 $session_id = str_replace('mxchat_channel_', '', $session_option);
4349
4350 // Create a unique key for this specific message
4351 $message_key = md5($session_id . $message_ts . $message_text);
4352 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
4353
4354 // Check if we've already processed this exact message
4355 if (in_array($message_key, $processed_messages)) {
4356 //error_log("Duplicate message detected for session $session_id");
4357 return new WP_REST_Response(['ok' => true]);
4358 }
4359
4360 // Add to processed messages
4361 $processed_messages[] = $message_key;
4362 // Keep only last 50 messages per session
4363 if (count($processed_messages) > 50) {
4364 $processed_messages = array_slice($processed_messages, -50);
4365 }
4366 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4367
4368 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4369
4370 // Handle agent ending the chat — transfer back to AI
4371 // Format: "!endchat" or "!endchat <custom message to user>"
4372 if (preg_match('/^!endchat\b/i', trim($message_text))) {
4373 update_option("mxchat_mode_{$session_id}", 'ai');
4374
4375 // Extract custom message after !endchat, or use empty string
4376 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
4377
4378 // Send the agent's custom farewell message if provided
4379 if (!empty($custom_message)) {
4380 $this->mxchat_save_chat_message($session_id, 'agent', $custom_message);
4381 }
4382
4383 // Confirm in Slack channel
4384 if (!empty($slack_bot_token)) {
4385 wp_remote_post('https://slack.com/api/chat.postMessage', [
4386 'headers' => [
4387 'Content-Type' => 'application/json',
4388 'Authorization' => 'Bearer ' . $slack_bot_token
4389 ],
4390 'body' => json_encode([
4391 'channel' => $channel_id,
4392 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
4393 'mrkdwn' => true
4394 ])
4395 ]);
4396 }
4397
4398 return new WP_REST_Response(['ok' => true]);
4399 }
4400
4401 // Save the agent message
4402 $this->mxchat_save_chat_message($session_id, 'agent', $message_text);
4403
4404 // Send confirmation back to Slack (only once)
4405 if (!empty($slack_bot_token)) {
4406 // Use a transient to prevent duplicate confirmations
4407 $confirm_key = 'mxchat_confirm_' . $message_key;
4408 if (!get_transient($confirm_key)) {
4409 wp_remote_post('https://slack.com/api/chat.postMessage', [
4410 'headers' => [
4411 'Content-Type' => 'application/json',
4412 'Authorization' => 'Bearer ' . $slack_bot_token
4413 ],
4414 'body' => json_encode([
4415 'channel' => $channel_id,
4416 'text' => "✅ _Message sent to user_",
4417 'thread_ts' => $event['ts'] // Reply in thread
4418 ])
4419 ]);
4420 // Set transient to prevent duplicate confirmations
4421 set_transient($confirm_key, true, 300); // 5 minutes
4422 }
4423 }
4424 }
4425 }
4426
4427 return new WP_REST_Response(['ok' => true]);
4428 }
4429
4430 // For the word upload handler
4431 public function mxchat_handle_word_upload() {
4432 // Delegate to word handler
4433 $this->word_handler->mxchat_handle_word_upload();
4434 }
4435
4436 // For the word removal handler
4437 public function mxchat_handle_word_remove() {
4438 // Delegate to word handler
4439 $this->word_handler->mxchat_handle_word_remove();
4440 }
4441
4442 // For the word status check
4443 public function mxchat_check_word_status() {
4444 // Delegate to word handler
4445 $this->word_handler->mxchat_check_word_status();
4446 }
4447
4448
4449 private function mxchat_get_user_identifier() {
4450 return MxChat_User::mxchat_get_user_identifier();
4451 }
4452
4453 private function mxchat_generate_embedding($text, $api_key) {
4454 try {
4455 // Get options and selected model
4456 $options = get_option('mxchat_options');
4457 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4458
4459 // Determine endpoint and API key based on model
4460 if (strpos($selected_model, 'voyage') === 0) {
4461 $endpoint = 'https://api.voyageai.com/v1/embeddings';
4462 $api_key = $options['voyage_api_key'] ?? '';
4463
4464 // Check if Voyage API key is missing
4465 if (empty($api_key)) {
4466 //error_log('Voyage API key is missing');
4467 return [
4468 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
4469 'error_code' => 'missing_voyage_api_key'
4470 ];
4471 }
4472 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4473 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4474 $api_key = $options['gemini_api_key'] ?? '';
4475
4476 // Check if Gemini API key is missing
4477 if (empty($api_key)) {
4478 //error_log('Gemini API key is missing');
4479 return [
4480 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
4481 'error_code' => 'missing_gemini_api_key'
4482 ];
4483 }
4484 } else {
4485 $endpoint = 'https://api.openai.com/v1/embeddings';
4486 // Use the passed API key for OpenAI
4487
4488 // Check if OpenAI API key is missing
4489 if (empty($api_key)) {
4490 //error_log('OpenAI API key is missing');
4491 return [
4492 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
4493 'error_code' => 'missing_openai_api_key'
4494 ];
4495 }
4496 }
4497
4498 // Check if text is empty
4499 if (empty($text)) {
4500 //error_log('Empty text provided for embedding generation');
4501 return [
4502 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
4503 'error_code' => 'empty_embedding_text'
4504 ];
4505 }
4506
4507 // Prepare request body based on provider
4508 if (strpos($selected_model, 'gemini-embedding') === 0) {
4509 // Gemini API format
4510 $request_body = [
4511 'model' => 'models/' . $selected_model,
4512 'content' => [
4513 'parts' => [
4514 ['text' => $text]
4515 ]
4516 ],
4517 'outputDimensionality' => 1536
4518 ];
4519
4520 // Prepare headers for Gemini (API key as query parameter)
4521 $endpoint .= '?key=' . $api_key;
4522 $headers = [
4523 'Content-Type' => 'application/json'
4524 ];
4525 } else {
4526 // OpenAI/Voyage API format
4527 $request_body = [
4528 'input' => $text,
4529 'model' => $selected_model
4530 ];
4531
4532 // Add output_dimension for voyage-3-large
4533 if ($selected_model === 'voyage-3-large') {
4534 $request_body['output_dimension'] = 2048;
4535 }
4536
4537 // Prepare headers for OpenAI/Voyage
4538 $headers = [
4539 'Content-Type' => 'application/json',
4540 'Authorization' => 'Bearer ' . $api_key
4541 ];
4542 }
4543
4544 // Prepare request arguments
4545 $args = [
4546 'body' => wp_json_encode($request_body),
4547 'headers' => $headers,
4548 'timeout' => 60,
4549 'redirection' => 5,
4550 'blocking' => true,
4551 'httpversion' => '1.0',
4552 'sslverify' => true,
4553 ];
4554
4555 // Make the request
4556 $response = wp_remote_post($endpoint, $args);
4557
4558 // Handle WordPress errors
4559 if (is_wp_error($response)) {
4560 $error_message = $response->get_error_message();
4561 //error_log('Embedding Generation Error: ' . $error_message);
4562 return [
4563 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
4564 'error_code' => 'embedding_connection_error'
4565 ];
4566 }
4567
4568 // Check HTTP status code
4569 $status_code = wp_remote_retrieve_response_code($response);
4570 if ($status_code !== 200) {
4571 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4572
4573 $error_message = isset($response_body['error']['message'])
4574 ? $response_body['error']['message']
4575 : 'HTTP Error ' . $status_code;
4576
4577 $error_type = isset($response_body['error']['type'])
4578 ? $response_body['error']['type']
4579 : 'unknown';
4580
4581 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
4582
4583 // Handle specific error types
4584 switch ($error_type) {
4585 case 'invalid_request_error':
4586 if (strpos($error_message, 'API key') !== false) {
4587 return [
4588 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
4589 'error_code' => 'embedding_invalid_api_key'
4590 ];
4591 }
4592 break;
4593
4594 case 'authentication_error':
4595 return [
4596 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
4597 'error_code' => 'embedding_auth_error'
4598 ];
4599
4600 case 'rate_limit_exceeded':
4601 return [
4602 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
4603 'error_code' => 'embedding_rate_limit'
4604 ];
4605
4606 case 'quota_exceeded':
4607 return [
4608 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
4609 'error_code' => 'embedding_quota_exceeded'
4610 ];
4611 }
4612
4613 // Generic error fallback
4614 return [
4615 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
4616 'error_code' => 'embedding_api_error',
4617 'status_code' => $status_code
4618 ];
4619 }
4620
4621 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4622
4623 // Handle different response formats based on provider
4624 if (strpos($selected_model, 'gemini-embedding') === 0) {
4625 // Gemini API response format
4626 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
4627 return $response_body['embedding']['values'];
4628 } else {
4629 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
4630 return [
4631 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
4632 'error_code' => 'invalid_gemini_embedding_response'
4633 ];
4634 }
4635 } else {
4636 // OpenAI/Voyage API response format
4637 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
4638 return $response_body['data'][0]['embedding'];
4639 } else {
4640 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
4641 return [
4642 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
4643 'error_code' => 'invalid_embedding_response'
4644 ];
4645 }
4646 }
4647 } catch (Exception $e) {
4648 //error_log('Embedding Exception: ' . $e->getMessage());
4649 return [
4650 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
4651 'error_code' => 'embedding_exception'
4652 ];
4653 }
4654 }
4655
4656
4657 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
4658 //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
4659
4660 // Check for OpenAI Vector Store first (takes priority when enabled)
4661 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
4662
4663 if ($bot_vectorstore_config['use_vectorstore']) {
4664 // Get current model to verify it's an OpenAI model
4665 $bot_options = $this->get_bot_options($bot_id);
4666 $mxchat_options = get_option('mxchat_options', array());
4667 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
4668 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
4669
4670 if ($this->is_openai_chat_model($selected_model)) {
4671 //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
4672 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
4673 } else {
4674 //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
4675 }
4676 }
4677
4678 // Get bot-specific Pinecone configuration
4679 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4680
4681 // Debug: Log the Pinecone configuration
4682 //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
4683 //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
4684 //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
4685 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
4686 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
4687
4688 // Determine whether to use Pinecone based on bot configuration
4689 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
4690
4691 //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
4692
4693 if ($use_pinecone) {
4694 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
4695 } else {
4696 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
4697 }
4698 }
4699
4700 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
4701 global $wpdb;
4702 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
4703 $cache_key = 'mxchat_system_prompt_embeddings_' . $bot_id;
4704 $batch_size = 500;
4705
4706 // Initialize similarity analysis storage
4707 $this->last_similarity_analysis = [
4708 'knowledge_base_type' => 'WordPress Database',
4709 'bot_id' => $bot_id,
4710 'top_matches' => [],
4711 'threshold_used' => 0,
4712 'total_checked' => 0
4713 ];
4714
4715 // NEW: Initialize valid URLs array
4716 $valid_urls = [];
4717
4718 // Get bot-specific options for similarity threshold
4719 $bot_options = $this->get_bot_options($bot_id);
4720 $current_options = !empty($bot_options) ? $bot_options : $this->options;
4721
4722 // Retrieve embeddings from cache or database
4723 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
4724 if ($embeddings === false) {
4725 // Cache miss - load embeddings from database WITH CONTENT and ROLE RESTRICTION for testing
4726 $embeddings = [];
4727 $offset = 0;
4728
4729 do {
4730 // Add bot_id filter if not default and if bot_metadata column exists
4731 $bot_filter = '';
4732 if ($bot_id !== 'default') {
4733 // Check if bot_metadata column exists
4734 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
4735 if ($column_exists) {
4736 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
4737 }
4738 }
4739
4740 $query = $wpdb->prepare(
4741 "SELECT id, embedding_vector, article_content, source_url, role_restriction
4742 FROM {$system_prompt_table}
4743 WHERE 1=1 {$bot_filter}
4744 LIMIT %d OFFSET %d",
4745 $batch_size,
4746 $offset
4747 );
4748
4749 $batch = $wpdb->get_results($query);
4750 if (empty($batch)) {
4751 break;
4752 }
4753
4754 $embeddings = array_merge($embeddings, $batch);
4755 $offset += $batch_size;
4756 unset($batch);
4757 } while (true);
4758
4759 if (empty($embeddings)) {
4760 // Store empty array for valid URLs since no content found
4761 $this->current_valid_urls = [];
4762 return '';
4763 }
4764
4765 // Cache embeddings for future use (but note: this now includes content and role restrictions)
4766 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
4767 }
4768
4769 // Get knowledge manager instance for role checking
4770 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4771
4772 // Get base similarity threshold from bot options or default options
4773 $similarity_threshold = isset($current_options['similarity_threshold'])
4774 ? ((int) $current_options['similarity_threshold']) / 100
4775 : 0.35;
4776
4777 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
4778
4779 // Calculate similarities and build results array
4780 $all_similarities = [];
4781 $url_groups = array(); // NEW: Group by source_url for chunk reassembly
4782
4783 foreach ($embeddings as $embedding) {
4784 $database_embedding = $embedding->embedding_vector
4785 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
4786 : null;
4787
4788 if (is_array($database_embedding) && is_array($user_embedding)) {
4789 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
4790
4791 // Check role access
4792 $role_restriction = $embedding->role_restriction ?? 'public';
4793 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
4794
4795 // Store ALL similarities for testing (top 10)
4796 $source_display = '';
4797 $source_url = $embedding->source_url ?? '';
4798 if (!empty($source_url) && $source_url !== '#') {
4799 $source_display = $source_url;
4800 } else {
4801 $content_preview = strip_tags($embedding->article_content ?? '');
4802 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
4803 $source_display = substr(trim($content_preview), 0, 50) . '...';
4804 }
4805
4806 // Parse chunk metadata for display
4807 $article_content_for_parse = $embedding->article_content ?? '';
4808 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
4809 $is_chunk = $parsed_for_display['is_chunked'];
4810 $chunk_meta = $parsed_for_display['metadata'];
4811
4812 $all_similarities[] = [
4813 'document_id' => $embedding->id,
4814 'similarity' => $similarity,
4815 'similarity_percentage' => round($similarity * 100, 2),
4816 'above_threshold' => $similarity >= $similarity_threshold,
4817 'source_display' => $source_display,
4818 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
4819 'used_for_context' => false,
4820 'role_restriction' => $role_restriction,
4821 'has_access' => $has_access,
4822 'filtered_out' => !$has_access,
4823 'is_chunk' => $is_chunk,
4824 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
4825 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
4826 ];
4827
4828 // Only consider results above threshold AND with access for content retrieval
4829 if ($similarity >= $similarity_threshold && $has_access) {
4830 // Parse chunk metadata if present
4831 $article_content = $embedding->article_content ?? '';
4832 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
4833 $is_chunked = $parsed['is_chunked'];
4834 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
4835 $text_content = $parsed['text'];
4836
4837 // Use a unique key for manual entries without a source URL
4838 $group_key = !empty($source_url) ? $source_url : '_manual_' . $embedding->id;
4839
4840 // Group by source URL (or unique key for manual entries)
4841 if (!isset($url_groups[$group_key])) {
4842 $url_groups[$group_key] = array(
4843 'source_url' => $source_url,
4844 'best_score' => 0,
4845 'is_chunked' => $is_chunked,
4846 'chunks' => array(),
4847 'single_text' => '',
4848 'single_id' => null
4849 );
4850 }
4851
4852 // Track best score for this group
4853 if ($similarity > $url_groups[$group_key]['best_score']) {
4854 $url_groups[$group_key]['best_score'] = $similarity;
4855 }
4856
4857 // Store chunk info or single text
4858 if ($is_chunked) {
4859 $url_groups[$group_key]['is_chunked'] = true;
4860 $url_groups[$group_key]['chunks'][] = array(
4861 'id' => $embedding->id,
4862 'score' => $similarity,
4863 'chunk_index' => $chunk_index,
4864 'text' => $text_content
4865 );
4866 } else {
4867 $url_groups[$group_key]['single_text'] = $text_content;
4868 $url_groups[$group_key]['single_id'] = $embedding->id;
4869 }
4870 }
4871 }
4872
4873 unset($database_embedding);
4874 }
4875
4876 // Sort ALL similarities for testing display (highest first)
4877 usort($all_similarities, function ($a, $b) {
4878 return $b['similarity'] <=> $a['similarity'];
4879 });
4880
4881 // Sort URL groups by best score (highest first)
4882 uasort($url_groups, function($a, $b) {
4883 return $b['best_score'] <=> $a['best_score'];
4884 });
4885
4886 // Get RAG sources limit from options (default 6, min 3, max 10)
4887 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 3;
4888 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
4889 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
4890
4891 // Take top N unique URLs based on user setting
4892 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
4893
4894 // Track which document IDs are used for context
4895 $used_document_ids = [];
4896 foreach ($top_urls as $group) {
4897 if ($group['is_chunked']) {
4898 foreach ($group['chunks'] as $chunk) {
4899 $used_document_ids[] = $chunk['id'];
4900 }
4901 } elseif ($group['single_id']) {
4902 $used_document_ids[] = $group['single_id'];
4903 }
4904 }
4905
4906 // Update the all_similarities array to mark which were actually used
4907 foreach ($all_similarities as &$similarity_item) {
4908 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
4909 }
4910
4911 // Store top 10 for testing panel
4912 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
4913 $this->last_similarity_analysis['total_checked'] = count($embeddings);
4914
4915 // Initialize final content
4916 $content = '';
4917 $matches_used = 0;
4918 $total_chunks_used = 0;
4919 $max_total_chunks = isset($options['rag_chunks_limit']) ? intval($options['rag_chunks_limit']) : 15;
4920 if ($max_total_chunks < 8) $max_total_chunks = 8;
4921 if ($max_total_chunks > 20) $max_total_chunks = 20;
4922 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
4923
4924 // Check if citation links are enabled (default to 'on' for backwards compatibility)
4925 // Use fresh options to ensure we get the latest setting value
4926 $fresh_options = get_option('mxchat_options', []);
4927 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
4928
4929 // Build content from top sources
4930 foreach ($top_urls as $group_key => $group) {
4931 $source_url = $group['source_url']; // Use actual source_url, not the group key
4932
4933 // Stop if we've hit the total chunk limit
4934 if ($total_chunks_used >= $max_total_chunks) {
4935 break;
4936 }
4937
4938 $full_text = '';
4939 $chunks_in_this_source = 1; // Default for non-chunked content
4940
4941 if ($group['is_chunked']) {
4942 // Calculate how many chunks we can still use (respect both total and per-source caps)
4943 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
4944
4945 // Fetch chunks for this URL with limit
4946 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
4947
4948 // If fetching all chunks fails, fall back to matched chunks
4949 if (empty($full_text)) {
4950 // Sort matched chunks by index and concatenate
4951 usort($group['chunks'], function($a, $b) {
4952 return $a['chunk_index'] <=> $b['chunk_index'];
4953 });
4954
4955 $chunk_texts = array();
4956 $chunks_in_this_source = 0;
4957 foreach ($group['chunks'] as $chunk) {
4958 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
4959 break;
4960 }
4961 $chunk_texts[] = $chunk['text'];
4962 $chunks_in_this_source++;
4963 }
4964 $full_text = implode("\n\n", $chunk_texts);
4965 }
4966 } else {
4967 $full_text = $group['single_text'];
4968 $chunks_in_this_source = 1;
4969 }
4970
4971 if (!empty($full_text)) {
4972 // Strip URLs from content if citation links are disabled
4973 if (!$citation_links_enabled) {
4974 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
4975 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
4976 }
4977
4978 // Use numbered reference for URL-based entries, plain info label for manual entries
4979 if (!empty($source_url) && $source_url !== '#') {
4980 $matches_used++;
4981 $content .= "## Reference " . $matches_used . " ##\n";
4982 $content .= $full_text . "\n\n";
4983
4984 // Only include citation URLs if citation links are enabled
4985 if ($citation_links_enabled) {
4986 $valid_urls[] = $source_url;
4987 $content .= "URL: " . $source_url . "\n\n";
4988 }
4989 } else {
4990 // Manual entry — no reference number, no citation
4991 $content .= "## Information ##\n";
4992 $content .= $full_text . "\n\n";
4993 }
4994
4995 // Extract any URLs from the text content itself (only if citation links enabled)
4996 if ($citation_links_enabled) {
4997 preg_match_all(
4998 '#\bhttps?://[^\s<>"\']+#i',
4999 $full_text,
5000 $content_urls
5001 );
5002 if (!empty($content_urls[0])) {
5003 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5004 }
5005 }
5006
5007 $total_chunks_used += $chunks_in_this_source;
5008 }
5009 }
5010
5011 // NEW: Store unique valid URLs for validation
5012 $this->current_valid_urls = array_unique($valid_urls);
5013
5014 // Store sources and chunks counts for testing/transcript display
5015 $this->last_similarity_analysis['sources_used'] = $matches_used;
5016 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5017
5018 // Add response guidelines
5019 if (empty($top_urls)) {
5020 $content = "No reference information was found for this query.\n\n";
5021 } else {
5022 // Build response guidelines based on citation links setting
5023 $content .= "\n## Response Guidelines ##\n" .
5024 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5025 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5026 "If you don't have specific information or are uncertain about any details, it's always " .
5027 "better to honestly say you don't know rather than making up or guessing at answers. " .
5028 "When information is incomplete, let them know you are unsure.\n\n";
5029
5030 // Only add hyperlink instructions if citation links are enabled
5031 if ($citation_links_enabled) {
5032 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5033 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5034 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5035 } else {
5036 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5037 "Simply provide helpful answers based on the reference information without citing sources.";
5038 }
5039 }
5040
5041 return trim($content);
5042 }
5043
5044 /**
5045 * Fetch and reassemble chunks for a URL from WordPress database
5046 *
5047 * @param string $source_url The source URL to fetch chunks for
5048 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
5049 * @param int &$chunk_count Reference to store the actual number of chunks returned
5050 * @return string Reassembled content from chunks
5051 */
5052 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
5053 global $wpdb;
5054 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
5055
5056 // Fetch all rows with this source_url
5057 $rows = $wpdb->get_results($wpdb->prepare(
5058 "SELECT article_content FROM {$table}
5059 WHERE source_url = %s
5060 ORDER BY id ASC",
5061 $source_url
5062 ));
5063
5064 if (empty($rows)) {
5065 $chunk_count = 0;
5066 return '';
5067 }
5068
5069 // Parse and sort chunks by index
5070 $chunks = array();
5071 foreach ($rows as $row) {
5072 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
5073
5074 if ($parsed['is_chunked']) {
5075 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
5076 $chunks[$chunk_index] = $parsed['text'];
5077 } else {
5078 // Non-chunked content - just return it
5079 $chunks[] = $parsed['text'];
5080 }
5081 }
5082
5083 // Sort by chunk index
5084 ksort($chunks);
5085
5086 // Apply chunk limit if specified
5087 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5088 $chunks = array_slice($chunks, 0, $max_chunks, true);
5089 }
5090
5091 // Store actual chunk count
5092 $chunk_count = count($chunks);
5093
5094 // Reassemble content
5095 return implode("\n\n", $chunks);
5096 }
5097
5098 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
5099 global $wpdb;
5100
5101 //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
5102 //error_log(" - bot_id: " . $bot_id);
5103 //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
5104 //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
5105
5106 // Use bot-specific config or fall back to default
5107 if ($bot_config === null) {
5108 $bot_config = $this->get_bot_pinecone_config($bot_id);
5109 }
5110
5111 $api_key = $bot_config['api_key'] ?? '';
5112 $host = $bot_config['host'] ?? '';
5113 $namespace = $bot_config['namespace'] ?? '';
5114
5115 //error_log("MXCHAT DEBUG: Pinecone query parameters:");
5116 //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
5117 //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
5118 //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
5119
5120 // Initialize similarity analysis storage
5121 $this->last_similarity_analysis = [
5122 'knowledge_base_type' => 'Pinecone',
5123 'bot_id' => $bot_id,
5124 'namespace' => $namespace,
5125 'top_matches' => [],
5126 'threshold_used' => 0,
5127 'total_checked' => 0
5128 ];
5129
5130 // NEW: Initialize valid URLs array
5131 $valid_urls = [];
5132
5133 if (empty($host) || empty($api_key)) {
5134 //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
5135 //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
5136 //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
5137 // Store empty array for valid URLs since we can't proceed
5138 $this->current_valid_urls = [];
5139 return '';
5140 }
5141
5142 // Get knowledge manager instance for role checking
5143 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5144
5145 // Get the similarity threshold from the bot options or main options
5146 $bot_options = $this->get_bot_options($bot_id);
5147 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
5148
5149 $similarity_threshold = isset($current_options['similarity_threshold'])
5150 ? ((int) $current_options['similarity_threshold']) / 100
5151 : 0.35;
5152
5153 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
5154
5155 // Prepare the query request for Pinecone
5156 $api_endpoint = "https://{$host}/query";
5157
5158 $request_body = array(
5159 'vector' => $user_embedding,
5160 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
5161 'includeMetadata' => true,
5162 'includeValues' => true
5163 );
5164
5165 // Add namespace if specified for this bot
5166 if (!empty($namespace)) {
5167 $request_body['namespace'] = $namespace;
5168 }
5169
5170 //error_log("MXCHAT DEBUG: About to call Pinecone API");
5171 //error_log(" - Endpoint: " . $api_endpoint);
5172 //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
5173
5174 $response = wp_remote_post($api_endpoint, array(
5175 'headers' => array(
5176 'Api-Key' => $api_key,
5177 'accept' => 'application/json',
5178 'content-type' => 'application/json'
5179 ),
5180 'body' => wp_json_encode($request_body),
5181 'timeout' => 30
5182 ));
5183
5184 if (is_wp_error($response)) {
5185 //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
5186 // Store empty array for valid URLs
5187 $this->current_valid_urls = [];
5188 return '';
5189 }
5190
5191 $response_code = wp_remote_retrieve_response_code($response);
5192 //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
5193
5194 if ($response_code !== 200) {
5195 $response_body = wp_remote_retrieve_body($response);
5196 //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
5197 // Store empty array for valid URLs
5198 $this->current_valid_urls = [];
5199 return '';
5200 }
5201
5202 // ADD DETAILED DEBUG SECTION HERE
5203 $response_body = wp_remote_retrieve_body($response);
5204 //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
5205
5206 $results = json_decode($response_body, true);
5207
5208 if (json_last_error() !== JSON_ERROR_NONE) {
5209 //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
5210 //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
5211 // Store empty array for valid URLs
5212 $this->current_valid_urls = [];
5213 return '';
5214 }
5215
5216 //error_log("MXCHAT DEBUG: Pinecone response structure:");
5217 //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
5218 //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
5219
5220 if (empty($results['matches'])) {
5221 //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
5222 //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
5223 // Store empty array for valid URLs
5224 $this->current_valid_urls = [];
5225 return '';
5226 }
5227
5228 //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
5229
5230 // Log first match details for debugging
5231 if (!empty($results['matches'][0])) {
5232 $first_match = $results['matches'][0];
5233 //error_log("MXCHAT DEBUG: First match details:");
5234 //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
5235 //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
5236 if (isset($first_match['metadata'])) {
5237 //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
5238 }
5239 }
5240
5241 // Initialize the final content
5242 $content = '';
5243 $matches_used = 0;
5244 $matches_used_for_context = [];
5245 $total_chunks_used = 0;
5246 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
5247 if ($max_total_chunks < 8) $max_total_chunks = 8;
5248 if ($max_total_chunks > 20) $max_total_chunks = 20;
5249 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
5250
5251 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5252 // Use fresh options to ensure we get the latest setting value
5253 $fresh_options = get_option('mxchat_options', []);
5254 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5255
5256 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
5257 $url_groups = array();
5258
5259 foreach ($results['matches'] as $index => $match) {
5260 // Skip if similarity is below threshold
5261 if ($match['score'] < $similarity_threshold) {
5262 continue;
5263 }
5264
5265 $metadata = $match['metadata'] ?? array();
5266 $source_url = $metadata['source_url'] ?? '';
5267 $match_id = $match['id'] ?? '';
5268
5269 // LAZY ROLE CHECK: Only check role for content we're actually considering
5270 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
5271 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5272
5273 // Skip if user doesn't have access
5274 if (!$has_access) {
5275 continue;
5276 }
5277
5278 // Use a unique key for manual entries without a source URL
5279 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
5280
5281 // Group by source URL (or unique key for manual entries)
5282 if (!isset($url_groups[$group_key])) {
5283 $url_groups[$group_key] = array(
5284 'source_url' => $source_url,
5285 'best_score' => 0,
5286 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
5287 'chunks' => array(),
5288 'single_text' => ''
5289 );
5290 }
5291
5292 // Track best score for this group
5293 if ($match['score'] > $url_groups[$group_key]['best_score']) {
5294 $url_groups[$group_key]['best_score'] = $match['score'];
5295 }
5296
5297 // Store chunk info or single text
5298 if ($url_groups[$group_key]['is_chunked']) {
5299 $url_groups[$group_key]['chunks'][] = array(
5300 'id' => $match_id,
5301 'score' => $match['score'],
5302 'chunk_index' => $metadata['chunk_index'] ?? 0,
5303 'text' => $metadata['text'] ?? ''
5304 );
5305 } else {
5306 // Non-chunked content - just store the text
5307 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
5308 $url_groups[$group_key]['single_id'] = $match_id;
5309 }
5310 }
5311
5312 // Sort URL groups by best score (highest first)
5313 uasort($url_groups, function($a, $b) {
5314 return $b['best_score'] <=> $a['best_score'];
5315 });
5316
5317 // Get RAG sources limit from options (default 6, min 3, max 10)
5318 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
5319 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
5320 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
5321
5322 // Take top N unique URLs based on user setting
5323 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
5324
5325 // Track which match IDs are actually used for context
5326 foreach ($top_urls as $group) {
5327 if ($group['is_chunked']) {
5328 foreach ($group['chunks'] as $chunk) {
5329 $matches_used_for_context[] = $chunk['id'];
5330 }
5331 } elseif (!empty($group['single_id'])) {
5332 $matches_used_for_context[] = $group['single_id'];
5333 }
5334 }
5335
5336 // Build content from top sources
5337 foreach ($top_urls as $group_key => $group) {
5338 $source_url = $group['source_url']; // Use actual source_url, not the group key
5339
5340 // Stop if we've hit the total chunk limit
5341 if ($total_chunks_used >= $max_total_chunks) {
5342 break;
5343 }
5344
5345 $full_text = '';
5346 $chunks_in_this_source = 1; // Default for non-chunked content
5347
5348 if ($group['is_chunked']) {
5349 // Calculate how many chunks we can still use (respect both total and per-source caps)
5350 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
5351
5352 // Fetch chunks for this URL with limit
5353 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
5354
5355 // If fetching all chunks fails, fall back to matched chunks
5356 if (empty($full_text)) {
5357 // Sort matched chunks by index and concatenate
5358 usort($group['chunks'], function($a, $b) {
5359 return $a['chunk_index'] <=> $b['chunk_index'];
5360 });
5361
5362 $chunk_texts = array();
5363 $chunks_in_this_source = 0;
5364 foreach ($group['chunks'] as $chunk) {
5365 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5366 break;
5367 }
5368 $chunk_texts[] = $chunk['text'];
5369 $chunks_in_this_source++;
5370 }
5371 $full_text = implode("\n\n", $chunk_texts);
5372 }
5373 } else {
5374 $full_text = $group['single_text'];
5375 $chunks_in_this_source = 1;
5376 }
5377
5378 if (!empty($full_text)) {
5379 // Strip URLs from content if citation links are disabled
5380 if (!$citation_links_enabled) {
5381 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5382 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5383 }
5384
5385 // Use numbered reference for URL-based entries, plain info label for manual entries
5386 if (!empty($source_url) && $source_url !== '#') {
5387 $matches_used++;
5388 $content .= "## Reference " . $matches_used . " ##\n";
5389 $content .= $full_text . "\n\n";
5390
5391 // Only include citation URLs if citation links are enabled
5392 if ($citation_links_enabled) {
5393 $valid_urls[] = $source_url;
5394 $content .= "URL: " . $source_url . "\n\n";
5395 }
5396 } else {
5397 // Manual entry — no reference number, no citation
5398 $content .= "## Information ##\n";
5399 $content .= $full_text . "\n\n";
5400 }
5401
5402 // Extract any URLs from the text content itself (only if citation links enabled)
5403 if ($citation_links_enabled) {
5404 preg_match_all(
5405 '#\bhttps?://[^\s<>"\']+#i',
5406 $full_text,
5407 $content_urls
5408 );
5409 if (!empty($content_urls[0])) {
5410 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5411 }
5412 }
5413
5414 $total_chunks_used += $chunks_in_this_source;
5415 }
5416 }
5417
5418 // Process ALL matches for testing data (top 10) - with role checking for testing display
5419 $all_matches = [];
5420 foreach ($results['matches'] as $index => $match) {
5421 if ($index >= 10) break; // Limit to top 10 for testing
5422
5423 $match_id = $match['id'] ?? '';
5424
5425 // Check role access for testing display (use cache if available)
5426 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
5427 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5428
5429 $source_display = '';
5430 if (!empty($match['metadata']['source_url'])) {
5431 $source_display = $match['metadata']['source_url'];
5432 } else {
5433 $content_preview = strip_tags($match['metadata']['text'] ?? '');
5434 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
5435 $source_display = substr(trim($content_preview), 0, 50) . '...';
5436 }
5437
5438 $match_id_for_display = $match['id'] ?? $index;
5439
5440 // Check for chunk metadata in Pinecone
5441 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
5442 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
5443 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
5444
5445 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
5446 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
5447 $is_chunk = true;
5448 }
5449
5450 $all_matches[] = [
5451 'document_id' => $match_id_for_display,
5452 'similarity' => $match['score'],
5453 'similarity_percentage' => round($match['score'] * 100, 2),
5454 'above_threshold' => $match['score'] >= $similarity_threshold,
5455 'source_display' => $source_display,
5456 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
5457 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
5458 'role_restriction' => $role_restriction,
5459 'has_access' => $has_access,
5460 'filtered_out' => !$has_access,
5461 'is_chunk' => $is_chunk,
5462 'chunk_index' => $chunk_index,
5463 'total_chunks' => $total_chunks
5464 ];
5465 }
5466
5467 // Store for testing panel
5468 $this->last_similarity_analysis['top_matches'] = $all_matches;
5469 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
5470 $this->last_similarity_analysis['sources_used'] = $matches_used;
5471 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5472
5473 // NEW: Store unique valid URLs for validation
5474 $this->current_valid_urls = array_unique($valid_urls);
5475
5476 // Add response guidelines
5477 if ($matches_used === 0) {
5478 $content = "No reference information was found for this query.\n\n";
5479 } else {
5480 // Build response guidelines based on citation links setting
5481 $content .= "\n## Response Guidelines ##\n" .
5482 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5483 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5484 "If you don't have specific information or are uncertain about any details, it's always " .
5485 "better to honestly say you don't know rather than making up or guessing at answers. " .
5486 "When information is incomplete, let them know you are unsure.\n\n";
5487
5488 // Only add hyperlink instructions if citation links are enabled
5489 if ($citation_links_enabled) {
5490 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5491 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5492 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5493 } else {
5494 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5495 "Simply provide helpful answers based on the reference information without citing sources.";
5496 }
5497 }
5498
5499 return trim($content);
5500 }
5501
5502 /**
5503 * Get role restriction for a single vector (with caching)
5504 */
5505 private function get_single_vector_role($vector_id, $metadata = array()) {
5506 global $wpdb;
5507
5508 if (empty($vector_id)) {
5509 return 'public';
5510 }
5511
5512 // Check cache first
5513 $cache_key = 'mxchat_vector_role_' . $vector_id;
5514 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
5515
5516 if ($cached_role !== false) {
5517 return $cached_role;
5518 }
5519
5520 $role_restriction = 'public';
5521
5522 // First try Pinecone metadata
5523 if (!empty($metadata['role_restriction'])) {
5524 $role_restriction = $metadata['role_restriction'];
5525 } else {
5526 // Check WordPress table for user-modified roles
5527 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5528 $stored_role = $wpdb->get_var($wpdb->prepare(
5529 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
5530 $vector_id
5531 ));
5532
5533 if ($stored_role) {
5534 $role_restriction = $stored_role;
5535 }
5536 }
5537
5538 // Cache individual role for 1 hour
5539 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
5540
5541 return $role_restriction;
5542 }
5543
5544 /**
5545 * Fetch and reassemble all chunks for a URL from Pinecone
5546 *
5547 * @param string $source_url The source URL to fetch chunks for
5548 * @param array $bot_config Bot-specific Pinecone configuration
5549 * @return string Reassembled content from all chunks
5550 */
5551 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
5552 $api_key = $bot_config['api_key'] ?? '';
5553 $host = $bot_config['host'] ?? '';
5554 $namespace = $bot_config['namespace'] ?? '';
5555
5556 if (empty($host) || empty($api_key)) {
5557 $chunk_count = 0;
5558 return '';
5559 }
5560
5561 $base_hash = md5($source_url);
5562
5563 // Use Pinecone list API to find all chunk vectors with this prefix
5564 $list_url = "https://{$host}/vectors/list";
5565
5566 // Limit to max_chunks if specified, otherwise fetch up to 100
5567 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
5568
5569 $list_body = array(
5570 'prefix' => $base_hash . '_chunk_',
5571 'limit' => $fetch_limit
5572 );
5573
5574 if (!empty($namespace)) {
5575 $list_body['namespace'] = $namespace;
5576 }
5577
5578 $list_response = wp_remote_post($list_url, array(
5579 'headers' => array(
5580 'Api-Key' => $api_key,
5581 'accept' => 'application/json',
5582 'content-type' => 'application/json'
5583 ),
5584 'body' => wp_json_encode($list_body),
5585 'timeout' => 30
5586 ));
5587
5588 if (is_wp_error($list_response)) {
5589 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
5590 return '';
5591 }
5592
5593 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
5594
5595 if (empty($list_data['vectors'])) {
5596 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
5597 return '';
5598 }
5599
5600 // Extract vector IDs
5601 $vector_ids = array();
5602 foreach ($list_data['vectors'] as $vector) {
5603 if (isset($vector['id'])) {
5604 $vector_ids[] = $vector['id'];
5605 }
5606 }
5607
5608 if (empty($vector_ids)) {
5609 return '';
5610 }
5611
5612 // Fetch all chunk content
5613 $fetch_url = "https://{$host}/vectors/fetch";
5614
5615 $fetch_body = array(
5616 'ids' => $vector_ids
5617 );
5618
5619 if (!empty($namespace)) {
5620 $fetch_body['namespace'] = $namespace;
5621 }
5622
5623 $fetch_response = wp_remote_post($fetch_url, array(
5624 'headers' => array(
5625 'Api-Key' => $api_key,
5626 'accept' => 'application/json',
5627 'content-type' => 'application/json'
5628 ),
5629 'body' => wp_json_encode($fetch_body),
5630 'timeout' => 30
5631 ));
5632
5633 if (is_wp_error($fetch_response)) {
5634 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
5635 return '';
5636 }
5637
5638 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
5639
5640 if (empty($fetch_data['vectors'])) {
5641 return '';
5642 }
5643
5644 // Sort chunks by index and reassemble
5645 $chunks = array();
5646 foreach ($fetch_data['vectors'] as $id => $vector) {
5647 $metadata = $vector['metadata'] ?? array();
5648 $chunk_index = $metadata['chunk_index'] ?? 0;
5649 $text = $metadata['text'] ?? '';
5650
5651 // Store chunk with its index
5652 $chunks[$chunk_index] = $text;
5653 }
5654
5655 // Sort by chunk index
5656 ksort($chunks);
5657
5658 // Apply chunk limit if specified
5659 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5660 $chunks = array_slice($chunks, 0, $max_chunks, true);
5661 }
5662
5663 // Store actual chunk count
5664 $chunk_count = count($chunks);
5665
5666 // Reassemble content
5667 return implode("\n\n", $chunks);
5668 }
5669
5670 /**
5671 * Search for relevant content using OpenAI Vector Store (File Search)
5672 *
5673 * @param string $user_query The user's query text
5674 * @param string $bot_id The bot ID
5675 * @param array $vectorstore_config Vector Store configuration
5676 * @return string Formatted context string with references
5677 */
5678 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
5679 //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
5680 //error_log(" - bot_id: " . $bot_id);
5681 //error_log(" - user_query length: " . strlen($user_query));
5682
5683 // Get OpenAI API key
5684 $mxchat_options = get_option('mxchat_options', array());
5685 $api_key = $mxchat_options['api_key'] ?? '';
5686
5687 // Reset vectorstore error tracking
5688 $this->last_vectorstore_error = null;
5689
5690 if (empty($api_key)) {
5691 //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
5692 $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
5693 $this->current_valid_urls = [];
5694 return '';
5695 }
5696
5697 // Get Vector Store configuration
5698 if (empty($vectorstore_config)) {
5699 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
5700 }
5701
5702 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
5703 $max_results = $vectorstore_config['max_results'] ?? 5;
5704
5705 if (empty($vectorstore_ids_string)) {
5706 //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
5707 $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
5708 $this->current_valid_urls = [];
5709 return '';
5710 }
5711
5712 // Parse Vector Store IDs
5713 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
5714 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
5715
5716 //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5717 //error_log("MXCHAT DEBUG: Max results: " . $max_results);
5718
5719 // Initialize similarity analysis storage
5720 $this->last_similarity_analysis = [
5721 'knowledge_base_type' => 'OpenAI Vector Store',
5722 'bot_id' => $bot_id,
5723 'vectorstore_ids' => $vectorstore_ids,
5724 'top_matches' => [],
5725 'threshold_used' => 0,
5726 'total_checked' => 0
5727 ];
5728
5729 $valid_urls = [];
5730
5731 // Get the selected model
5732 $bot_options = $this->get_bot_options($bot_id);
5733 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
5734 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
5735
5736 // Verify it's an OpenAI model
5737 if (!$this->is_openai_chat_model($selected_model)) {
5738 //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
5739 $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
5740 $this->current_valid_urls = [];
5741 return '';
5742 }
5743
5744 // Use OpenAI Responses API with file_search tool
5745 $request_body = array(
5746 'model' => $selected_model,
5747 'input' => $user_query,
5748 'tools' => array(
5749 array(
5750 'type' => 'file_search',
5751 'vector_store_ids' => $vectorstore_ids,
5752 'max_num_results' => intval($max_results)
5753 )
5754 ),
5755 'include' => array('output[*].file_search_call.search_results')
5756 );
5757
5758 //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
5759 //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
5760 //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
5761 //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5762 //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
5763 //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
5764
5765 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
5766 'headers' => array(
5767 'Authorization' => 'Bearer ' . $api_key,
5768 'Content-Type' => 'application/json'
5769 ),
5770 'body' => wp_json_encode($request_body),
5771 'timeout' => 60
5772 ));
5773
5774 if (is_wp_error($response)) {
5775 //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
5776 $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
5777 $this->current_valid_urls = [];
5778 return '';
5779 }
5780
5781 $response_code = wp_remote_retrieve_response_code($response);
5782 //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
5783
5784 $response_body = wp_remote_retrieve_body($response);
5785 //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
5786
5787 if ($response_code !== 200) {
5788 //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
5789 $api_error_detail = '';
5790 $decoded_error = json_decode($response_body, true);
5791 if (isset($decoded_error['error']['message'])) {
5792 $api_error_detail = $decoded_error['error']['message'];
5793 }
5794 $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
5795 $this->current_valid_urls = [];
5796 return '';
5797 }
5798 $result = json_decode($response_body, true);
5799
5800 if (json_last_error() !== JSON_ERROR_NONE) {
5801 //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
5802 $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
5803 $this->current_valid_urls = [];
5804 return '';
5805 }
5806
5807 // Debug: Log the structure of the result
5808 //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
5809 if (isset($result['output'])) {
5810 //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
5811 foreach ($result['output'] as $idx => $out) {
5812 //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
5813 //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
5814 }
5815 } else {
5816 //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
5817 }
5818
5819 // Extract file search results from the response
5820 $content = '';
5821 $matches_used = 0;
5822 $all_matches = [];
5823
5824 // The Responses API returns output array with tool results
5825 if (isset($result['output']) && is_array($result['output'])) {
5826 foreach ($result['output'] as $output_item) {
5827 // Look for file_search_call results
5828 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
5829 //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
5830 //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
5831
5832 // Check for search_results in the output item directly
5833 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
5834 //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
5835
5836 if (empty($search_results)) {
5837 //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
5838 //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
5839 }
5840
5841 foreach ($search_results as $index => $search_result) {
5842 $filename = $search_result['filename'] ?? '';
5843 $score = $search_result['score'] ?? 0;
5844 $text_content = '';
5845
5846 // Extract text content from the result
5847 // The text can be directly on the result OR nested under content array
5848 if (isset($search_result['text']) && !empty($search_result['text'])) {
5849 // Direct text field (OpenAI's actual format)
5850 $text_content = $search_result['text'];
5851 //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
5852 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
5853 // Nested content array format
5854 foreach ($search_result['content'] as $content_item) {
5855 if (isset($content_item['text'])) {
5856 $text_content .= $content_item['text'] . "\n";
5857 }
5858 }
5859 //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
5860 } else {
5861 //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
5862 }
5863
5864 if (!empty($text_content)) {
5865 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5866 $content .= trim($text_content) . "\n\n";
5867
5868 if (!empty($filename)) {
5869 $content .= "Source: " . $filename . "\n\n";
5870 }
5871
5872 // Extract URLs from content
5873 preg_match_all(
5874 '#\bhttps?://[^\s<>"\']+#i',
5875 $text_content,
5876 $content_urls
5877 );
5878 if (!empty($content_urls[0])) {
5879 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5880 }
5881
5882 $matches_used++;
5883 }
5884
5885 // Store for similarity analysis
5886 $all_matches[] = [
5887 'document_id' => $filename ?: ('result_' . $index),
5888 'similarity' => $score,
5889 'similarity_percentage' => round($score * 100, 2),
5890 'above_threshold' => true,
5891 'source_display' => $filename,
5892 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5893 'used_for_context' => true,
5894 'role_restriction' => 'public',
5895 'has_access' => true,
5896 'filtered_out' => false
5897 ];
5898 }
5899 }
5900
5901 // Also check for message content with annotations (citations)
5902 if (isset($output_item['type']) && $output_item['type'] === 'message') {
5903 if (isset($output_item['content']) && is_array($output_item['content'])) {
5904 foreach ($output_item['content'] as $content_block) {
5905 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
5906 foreach ($content_block['annotations'] as $annotation) {
5907 if (isset($annotation['filename'])) {
5908 $filename = $annotation['filename'];
5909 $score = $annotation['score'] ?? 0;
5910 $text_content = '';
5911
5912 if (isset($annotation['content']) && is_array($annotation['content'])) {
5913 foreach ($annotation['content'] as $ann_content) {
5914 if (isset($ann_content['text'])) {
5915 $text_content .= $ann_content['text'] . "\n";
5916 }
5917 }
5918 }
5919
5920 if (!empty($text_content) && $matches_used < $max_results) {
5921 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5922 $content .= trim($text_content) . "\n\n";
5923 $content .= "Source: " . $filename . "\n\n";
5924
5925 preg_match_all(
5926 '#\bhttps?://[^\s<>"\']+#i',
5927 $text_content,
5928 $content_urls
5929 );
5930 if (!empty($content_urls[0])) {
5931 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5932 }
5933
5934 $matches_used++;
5935
5936 $all_matches[] = [
5937 'document_id' => $filename,
5938 'similarity' => $score,
5939 'similarity_percentage' => round($score * 100, 2),
5940 'above_threshold' => true,
5941 'source_display' => $filename,
5942 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5943 'used_for_context' => true,
5944 'role_restriction' => 'public',
5945 'has_access' => true,
5946 'filtered_out' => false
5947 ];
5948 }
5949 }
5950 }
5951 }
5952 }
5953 }
5954 }
5955 }
5956 }
5957
5958 // Store for testing panel
5959 $this->last_similarity_analysis['top_matches'] = $all_matches;
5960 $this->last_similarity_analysis['total_checked'] = count($all_matches);
5961
5962 // Store unique valid URLs for validation
5963 $this->current_valid_urls = array_unique($valid_urls);
5964
5965 //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
5966 //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
5967 //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
5968 //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
5969 if ($matches_used > 0) {
5970 //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
5971 }
5972
5973 // Check if citation links are enabled
5974 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
5975
5976 // Add response guidelines
5977 if ($matches_used === 0) {
5978 //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
5979 $content = "No reference information was found for this query.\n\n";
5980 } else {
5981 // Build response guidelines based on citation links setting
5982 $content .= "\n## Response Guidelines ##\n" .
5983 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5984 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5985 "If you don't have specific information or are uncertain about any details, it's always " .
5986 "better to honestly say you don't know rather than making up or guessing at answers. " .
5987 "When information is incomplete, let them know you are unsure.\n\n";
5988
5989 // Only add hyperlink instructions if citation links are enabled
5990 if ($citation_links_enabled) {
5991 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5992 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
5993 } else {
5994 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5995 "Simply provide helpful answers based on the reference information without citing sources.";
5996 }
5997 }
5998
5999 //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
6000
6001 return trim($content);
6002 }
6003
6004 /**
6005 * Check if the given model is an OpenAI chat model
6006 *
6007 * @param string $model The model ID
6008 * @return bool True if it's an OpenAI model
6009 */
6010 private function is_openai_chat_model($model) {
6011 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
6012 foreach ($openai_prefixes as $prefix) {
6013 if (strpos($model, $prefix) === 0) {
6014 return true;
6015 }
6016 }
6017 return false;
6018 }
6019
6020 /**
6021 * Get bot-specific Vector Store configuration
6022 *
6023 * @param string $bot_id The bot ID
6024 * @return array Configuration array
6025 */
6026 private function get_bot_vectorstore_config($bot_id = 'default') {
6027 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
6028
6029 // Default global settings
6030 $default_config = array(
6031 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
6032 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
6033 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
6034 );
6035
6036 // Allow multi-bot plugin to override with bot-specific settings
6037 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
6038
6039 // Preserve max_results from global settings if not set in bot config
6040 if (!isset($bot_config['max_results'])) {
6041 $bot_config['max_results'] = $default_config['max_results'];
6042 }
6043
6044 return $bot_config;
6045 }
6046
6047 private function mxchat_find_relevant_products($user_embedding) {
6048 //error_log('MXChat Vector Search: Starting product search...');
6049
6050 // Retrieve the add-on settings from the database
6051 $addon_options = get_option('mxchat_pinecone_addon_options', array());
6052
6053 // Determine whether Pinecone is enabled
6054 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
6055
6056 //error_log('Pinecone enabled flag: ' . $use_pinecone);
6057
6058 if ($use_pinecone === 1) {
6059 //error_log('MXChat Vector Search: Using Pinecone database for products');
6060 return $this->find_relevant_products_pinecone($user_embedding);
6061 } else {
6062 //error_log('MXChat Vector Search: Using WordPress database for products');
6063 return $this->find_relevant_products_wordpress($user_embedding);
6064 }
6065 }
6066 private function find_relevant_products_wordpress($user_embedding) {
6067 global $wpdb;
6068 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6069 $cache_key = 'mxchat_system_prompt_embeddings';
6070 $batch_size = 500;
6071
6072 // Original WordPress database search logic
6073 // [Previous implementation remains the same]
6074 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
6075 if ($embeddings === false) {
6076 $embeddings = [];
6077 $offset = 0;
6078
6079 do {
6080 $query = $wpdb->prepare(
6081 "SELECT id, embedding_vector
6082 FROM {$system_prompt_table}
6083 LIMIT %d OFFSET %d",
6084 $batch_size,
6085 $offset
6086 );
6087
6088 $batch = $wpdb->get_results($query);
6089 if (empty($batch)) {
6090 break;
6091 }
6092
6093 $embeddings = array_merge($embeddings, $batch);
6094 $offset += $batch_size;
6095
6096 unset($batch);
6097
6098 } while (true);
6099
6100 if (empty($embeddings)) {
6101 return '';
6102 }
6103 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
6104 }
6105
6106 $relevant_results = [];
6107 foreach ($embeddings as $embedding) {
6108 $database_embedding = $embedding->embedding_vector
6109 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
6110 : null;
6111 if (is_array($database_embedding) && is_array($user_embedding)) {
6112 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6113 $relevant_results[] = [
6114 'id' => $embedding->id,
6115 'similarity' => $similarity
6116 ];
6117 }
6118 unset($database_embedding);
6119 }
6120
6121 // Use fixed threshold for products
6122 $similarity_threshold = 0.85;
6123
6124 $relevant_results = array_filter($relevant_results, function ($result) use ($similarity_threshold) {
6125 return $result['similarity'] >= $similarity_threshold;
6126 });
6127 usort($relevant_results, function ($a, $b) {
6128 return $b['similarity'] <=> $a['similarity'];
6129 });
6130
6131 $top_results = array_slice($relevant_results, 0, 3);
6132 $content = '';
6133
6134 foreach ($top_results as $result) {
6135 $chunk_content = $this->fetch_content_with_product_links($result['id']);
6136 $content .= $chunk_content . "\n\n";
6137 }
6138
6139 return trim($content);
6140 }
6141
6142
6143 private function find_relevant_products_pinecone($user_embedding) {
6144 //error_log('Starting Pinecone product search...');
6145
6146 $options = get_option('mxchat_pinecone_addon_options', array());
6147 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
6148 $host = $options['mxchat_pinecone_host'] ?? '';
6149
6150 if (empty($host) || empty($api_key)) {
6151 //error_log('Pinecone credentials not properly configured for product search');
6152 return '';
6153 }
6154
6155 $similarity_threshold = 0.85;
6156 $api_endpoint = "https://{$host}/query";
6157
6158 $request_body = array(
6159 'vector' => $user_embedding,
6160 'topK' => 5,
6161 'includeMetadata' => true,
6162 'includeValues' => true,
6163 'filter' => array(
6164 'type' => 'product'
6165 )
6166 );
6167
6168 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
6169
6170 $response = wp_remote_post($api_endpoint, array(
6171 'headers' => array(
6172 'Api-Key' => $api_key,
6173 'accept' => 'application/json',
6174 'content-type' => 'application/json'
6175 ),
6176 'body' => wp_json_encode($request_body),
6177 'timeout' => 30
6178 ));
6179
6180 if (is_wp_error($response)) {
6181 //error_log('Pinecone product query error: ' . $response->get_error_message());
6182 return '';
6183 }
6184
6185 $response_code = wp_remote_retrieve_response_code($response);
6186 //error_log('Pinecone response code: ' . $response_code);
6187
6188 if ($response_code !== 200) {
6189 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
6190 return '';
6191 }
6192
6193 $results = json_decode(wp_remote_retrieve_body($response), true);
6194 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
6195
6196 if (empty($results['matches'])) {
6197 //error_log('No matches found in Pinecone response');
6198 return '';
6199 }
6200
6201 $content = '';
6202 foreach ($results['matches'] as $match) {
6203 if ($match['score'] < $similarity_threshold) {
6204 //error_log("Match below threshold: " . $match['score']);
6205 continue;
6206 }
6207
6208 if (!empty($match['metadata']['text'])) {
6209 $content .= $match['metadata']['text'];
6210 if (!empty($match['metadata']['source_url'])) {
6211 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
6212 }
6213 $content .= "\n\n";
6214 }
6215 }
6216
6217 return trim($content);
6218 }
6219
6220
6221 private function fetch_content_with_product_links($most_relevant_id) {
6222 global $wpdb;
6223 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6224
6225 // Fetch the article content and associated product URL
6226 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
6227 $result = $wpdb->get_row($query);
6228
6229 if ($result) {
6230 // Append the product link to the content if available
6231 $content = $result->article_content;
6232 if (!empty($result->source_url)) {
6233 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
6234 }
6235 return $content;
6236 }
6237
6238 return null;
6239 }
6240
6241 /**
6242 * Get system instructions for a specific bot or default
6243 * Checks for multi-bot add-on and uses bot-specific instructions if available
6244 * Automatically strips URLs if citation links are disabled
6245 * Replaces {visitor_name} placeholder with actual visitor name if available
6246 *
6247 * @param string $bot_id The bot ID to get instructions for
6248 * @param string $session_id Optional session ID to lookup visitor name
6249 */
6250 private function get_system_instructions($bot_id = 'default', $session_id = '') {
6251 $instructions = '';
6252
6253 // Check if multi-bot add-on is active
6254 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
6255 // Get bot-specific options from multi-bot add-on
6256 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
6257
6258 // If bot has custom system instructions, use those
6259 if (!empty($bot_options['system_prompt_instructions'])) {
6260 $instructions = $bot_options['system_prompt_instructions'];
6261 }
6262 }
6263
6264 // Fall back to default system instructions
6265 if (empty($instructions)) {
6266 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
6267 }
6268
6269 // Check if citation links are disabled - if so, strip URLs from instructions
6270 $fresh_options = get_option('mxchat_options', []);
6271 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6272
6273 if (!$citation_links_enabled && !empty($instructions)) {
6274 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
6275 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
6276 }
6277
6278 // Replace {visitor_name} placeholder with actual visitor name if available
6279 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
6280 $name_option_key = "mxchat_name_{$session_id}";
6281 $visitor_name = get_option($name_option_key, '');
6282
6283 if (!empty($visitor_name)) {
6284 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
6285 } else {
6286 // Remove placeholder if no name is available
6287 $instructions = str_ireplace('{visitor_name}', '', $instructions);
6288 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
6289 }
6290 }
6291
6292 // Allow developers to filter system instructions and process shortcodes
6293 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
6294 $instructions = do_shortcode($instructions);
6295
6296 return $instructions;
6297 }
6298 /**
6299 * Get the current bot ID from session or request context
6300 */
6301 private function get_current_bot_id($session_id = '') {
6302 // First, check if bot_id is passed in the current request
6303 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
6304 return sanitize_key($_POST['bot_id']);
6305 }
6306
6307 // If not in POST, try to get it from session data
6308 if (!empty($session_id)) {
6309 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
6310 if (!empty($bot_id)) {
6311 return $bot_id;
6312 }
6313 }
6314
6315 // Fall back to default
6316 return 'default';
6317 }
6318 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') {
6319 try {
6320 if (!$relevant_content) {
6321 $error_response = [
6322 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
6323 'error_code' => 'no_relevant_content'
6324 ];
6325
6326 if ($testing_data !== null) {
6327 $error_response['testing_data'] = $testing_data;
6328 }
6329
6330 return $error_response;
6331 }
6332
6333 if (!is_array($conversation_history)) {
6334 $conversation_history = array();
6335 }
6336
6337 // Check if this is an OpenRouter model
6338 if ($selected_model === 'openrouter') {
6339 // Get the actual OpenRouter model from options
6340 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
6341
6342 if (empty($openrouter_selected_model)) {
6343 $error_response = [
6344 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
6345 'error_code' => 'no_openrouter_model_selected'
6346 ];
6347 if ($testing_data !== null) {
6348 $error_response['testing_data'] = $testing_data;
6349 }
6350 return $error_response;
6351 }
6352
6353 if (empty($openrouter_api_key)) {
6354 $error_response = [
6355 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
6356 'error_code' => 'missing_openrouter_api_key'
6357 ];
6358 if ($testing_data !== null) {
6359 $error_response['testing_data'] = $testing_data;
6360 }
6361 return $error_response;
6362 }
6363
6364 if ($streaming) {
6365 return $this->mxchat_generate_response_openrouter_stream(
6366 $openrouter_selected_model,
6367 $openrouter_api_key,
6368 $conversation_history,
6369 $relevant_content,
6370 $session_id,
6371 $testing_data
6372 );
6373 } else {
6374 $response = $this->mxchat_generate_response_openrouter(
6375 $openrouter_selected_model,
6376 $openrouter_api_key,
6377 $conversation_history,
6378 $relevant_content
6379 );
6380 }
6381
6382 if (is_array($response) && isset($response['error'])) {
6383 if ($testing_data !== null) {
6384 $response['testing_data'] = $testing_data;
6385 }
6386 return $response;
6387 }
6388
6389 return $response;
6390 }
6391
6392 // Extract model prefix to determine the provider
6393 $model_parts = explode('-', $selected_model);
6394 $provider = strtolower($model_parts[0]);
6395
6396 // Handle model selection based on provider prefix
6397 switch ($provider) {
6398 case 'gemini':
6399 if (empty($gemini_api_key)) {
6400 $error_response = [
6401 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
6402 'error_code' => 'missing_gemini_api_key'
6403 ];
6404 if ($testing_data !== null) {
6405 $error_response['testing_data'] = $testing_data;
6406 }
6407 return $error_response;
6408 }
6409 $response = $this->mxchat_generate_response_gemini(
6410 $selected_model,
6411 $gemini_api_key,
6412 $conversation_history,
6413 $relevant_content
6414 );
6415 break;
6416
6417 case 'claude':
6418 if (empty($claude_api_key)) {
6419 $error_response = [
6420 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
6421 'error_code' => 'missing_claude_api_key'
6422 ];
6423 if ($testing_data !== null) {
6424 $error_response['testing_data'] = $testing_data;
6425 }
6426 return $error_response;
6427 }
6428 if ($streaming) {
6429 return $this->mxchat_generate_response_claude_stream(
6430 $selected_model,
6431 $claude_api_key,
6432 $conversation_history,
6433 $relevant_content,
6434 $session_id,
6435 $testing_data
6436 );
6437 } else {
6438 $response = $this->mxchat_generate_response_claude(
6439 $selected_model,
6440 $claude_api_key,
6441 $conversation_history,
6442 $relevant_content
6443 );
6444 }
6445 break;
6446
6447 case 'grok':
6448 if (empty($xai_api_key)) {
6449 $error_response = [
6450 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
6451 'error_code' => 'missing_xai_api_key'
6452 ];
6453 if ($testing_data !== null) {
6454 $error_response['testing_data'] = $testing_data;
6455 }
6456 return $error_response;
6457 }
6458 if ($streaming) {
6459 return $this->mxchat_generate_response_xai_stream(
6460 $selected_model,
6461 $xai_api_key,
6462 $conversation_history,
6463 $relevant_content,
6464 $session_id,
6465 $testing_data
6466 );
6467 } else {
6468 $response = $this->mxchat_generate_response_xai(
6469 $selected_model,
6470 $xai_api_key,
6471 $conversation_history,
6472 $relevant_content
6473 );
6474 }
6475 break;
6476
6477 case 'deepseek':
6478 if (empty($deepseek_api_key)) {
6479 $error_response = [
6480 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
6481 'error_code' => 'missing_deepseek_api_key'
6482 ];
6483 if ($testing_data !== null) {
6484 $error_response['testing_data'] = $testing_data;
6485 }
6486 return $error_response;
6487 }
6488 if ($streaming) {
6489 return $this->mxchat_generate_response_deepseek_stream(
6490 $selected_model,
6491 $deepseek_api_key,
6492 $conversation_history,
6493 $relevant_content,
6494 $session_id,
6495 $testing_data
6496 );
6497 } else {
6498 $response = $this->mxchat_generate_response_deepseek(
6499 $selected_model,
6500 $deepseek_api_key,
6501 $conversation_history,
6502 $relevant_content
6503 );
6504 }
6505 break;
6506
6507 case 'gpt':
6508 case 'o1':
6509 if (empty($api_key)) {
6510 $error_response = [
6511 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6512 'error_code' => 'missing_openai_api_key'
6513 ];
6514 if ($testing_data !== null) {
6515 $error_response['testing_data'] = $testing_data;
6516 }
6517 return $error_response;
6518 }
6519
6520 // Check if web search is enabled for this OpenAI model
6521 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6522 // Models that don't support web search
6523 $unsupported_web_search_models = array('gpt-4.1-nano');
6524 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6525
6526 if ($web_search_enabled && $model_supports_web_search) {
6527 // Use Responses API (required for some models, or when web search is enabled)
6528 return $this->mxchat_generate_response_openai_web_search(
6529 $selected_model,
6530 $api_key,
6531 $conversation_history,
6532 $relevant_content,
6533 $session_id,
6534 $testing_data,
6535 $streaming
6536 );
6537 } elseif ($streaming) {
6538 return $this->mxchat_generate_response_openai_stream(
6539 $selected_model,
6540 $api_key,
6541 $conversation_history,
6542 $relevant_content,
6543 $session_id,
6544 $testing_data
6545 );
6546 } else {
6547 $response = $this->mxchat_generate_response_openai(
6548 $selected_model,
6549 $api_key,
6550 $conversation_history,
6551 $relevant_content
6552 );
6553 }
6554 break;
6555
6556 default:
6557 if (empty($api_key)) {
6558 $error_response = [
6559 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6560 'error_code' => 'missing_openai_api_key'
6561 ];
6562 if ($testing_data !== null) {
6563 $error_response['testing_data'] = $testing_data;
6564 }
6565 return $error_response;
6566 }
6567
6568 // Check if web search is enabled (default case also handles OpenAI models)
6569 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6570 $unsupported_web_search_models = array('gpt-4.1-nano');
6571 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6572
6573 if ($web_search_enabled && $model_supports_web_search) {
6574 return $this->mxchat_generate_response_openai_web_search(
6575 $selected_model,
6576 $api_key,
6577 $conversation_history,
6578 $relevant_content,
6579 $session_id,
6580 $testing_data,
6581 $streaming
6582 );
6583 } elseif ($streaming) {
6584 return $this->mxchat_generate_response_openai_stream(
6585 $selected_model,
6586 $api_key,
6587 $conversation_history,
6588 $relevant_content,
6589 $session_id,
6590 $testing_data
6591 );
6592 } else {
6593 $response = $this->mxchat_generate_response_openai(
6594 $selected_model,
6595 $api_key,
6596 $conversation_history,
6597 $relevant_content
6598 );
6599 }
6600 break;
6601 }
6602
6603 if (is_array($response) && isset($response['error'])) {
6604 if ($testing_data !== null) {
6605 $response['testing_data'] = $testing_data;
6606 }
6607 return $response;
6608 }
6609
6610 return $response;
6611
6612 } catch (Exception $e) {
6613 $error_response = [
6614 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
6615 'error_code' => 'system_exception',
6616 'exception_details' => $e->getMessage()
6617 ];
6618
6619 if ($testing_data !== null) {
6620 $error_response['testing_data'] = $testing_data;
6621 }
6622
6623 return $error_response;
6624 }
6625 }
6626 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6627 try {
6628 $bot_id = $this->get_current_bot_id($session_id);
6629 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6630
6631 if (!is_array($conversation_history)) {
6632 $conversation_history = array();
6633 }
6634
6635 $formatted_conversation = array();
6636
6637 $formatted_conversation[] = array(
6638 'role' => 'system',
6639 'content' => $system_prompt_instructions . " " . $relevant_content
6640 );
6641
6642 foreach ($conversation_history as $message) {
6643 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6644 $role = $message['role'];
6645 if ($role === 'bot' || $role === 'agent') {
6646 $role = 'assistant';
6647 }
6648 if (!in_array($role, ['system', 'assistant', 'user'])) {
6649 $role = 'user';
6650 }
6651 $formatted_conversation[] = array(
6652 'role' => $role,
6653 'content' => $message['content']
6654 );
6655 }
6656 }
6657
6658 if (headers_sent() || !function_exists('curl_init')) {
6659 $regular_response = $this->mxchat_generate_response_openrouter(
6660 $selected_model,
6661 $openrouter_api_key,
6662 $conversation_history,
6663 $relevant_content
6664 );
6665
6666 // Save bot response to transcript
6667 if (!empty($regular_response) && !empty($session_id)) {
6668 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6669 }
6670
6671 $response_data = [
6672 'text' => $regular_response,
6673 'html' => '',
6674 'session_id' => $session_id
6675 ];
6676
6677 if ($testing_data !== null) {
6678 $response_data['testing_data'] = $testing_data;
6679 }
6680
6681 header('Content-Type: application/json');
6682 echo json_encode($response_data);
6683 return true;
6684 }
6685
6686 $body = json_encode([
6687 'model' => $selected_model,
6688 'messages' => $formatted_conversation,
6689 'temperature' => 1,
6690 'stream' => true
6691 ]);
6692
6693 // Setup streaming headers now that we know we're actually streaming
6694 $this->setup_streaming_headers();
6695
6696 $ch = curl_init();
6697 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
6698 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6699 curl_setopt($ch, CURLOPT_POST, true);
6700 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6701 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6702 'Content-Type: application/json',
6703 'Authorization: Bearer ' . $openrouter_api_key,
6704 'HTTP-Referer: ' . home_url(),
6705 'X-Title: ' . get_bloginfo('name')
6706 ));
6707 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6708 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6709
6710 $full_response = '';
6711 $stream_started = false;
6712 $buffer = '';
6713
6714 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6715 if (!$stream_started && $testing_data !== null) {
6716 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6717 flush();
6718 $stream_started = true;
6719 }
6720
6721 $buffer .= $data;
6722 $lines = explode("\n", $buffer);
6723 $buffer = array_pop($lines);
6724
6725 foreach ($lines as $line) {
6726 if (trim($line) === '') {
6727 continue;
6728 }
6729
6730 if (strpos($line, 'data: ') !== 0) {
6731 continue;
6732 }
6733
6734 $json_str = substr($line, 6);
6735
6736 if (trim($json_str) === '[DONE]') {
6737 echo "data: [DONE]\n\n";
6738 flush();
6739 continue;
6740 }
6741
6742 $json = json_decode(trim($json_str), true);
6743 if ($json && isset($json['choices'][0]['delta']['content'])) {
6744 $content = $json['choices'][0]['delta']['content'];
6745 $full_response .= $content;
6746
6747 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6748 flush();
6749 }
6750 }
6751
6752 return strlen($data);
6753 });
6754
6755 $response = curl_exec($ch);
6756 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6757
6758 if (curl_errno($ch) || $http_code !== 200) {
6759 curl_close($ch);
6760
6761 $regular_response = $this->mxchat_generate_response_openrouter(
6762 $selected_model,
6763 $openrouter_api_key,
6764 $conversation_history,
6765 $relevant_content
6766 );
6767
6768 $response_data = [
6769 'text' => $regular_response,
6770 'html' => '',
6771 'session_id' => $session_id
6772 ];
6773
6774 if ($testing_data !== null) {
6775 $response_data['testing_data'] = $testing_data;
6776 }
6777
6778 header('Content-Type: application/json');
6779 echo json_encode($response_data);
6780 return true;
6781 }
6782
6783 curl_close($ch);
6784
6785 if (!empty($full_response) && !empty($session_id)) {
6786 // Prepare RAG context for streaming response
6787 $rag_context_for_storage = null;
6788 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
6789 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
6790
6791 if ($has_rag_data || $has_action_data) {
6792 $rag_context_for_storage = [];
6793
6794 if ($has_rag_data) {
6795 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
6796 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
6797 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
6798 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
6799 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
6800 }
6801
6802 if ($has_action_data) {
6803 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
6804 }
6805 }
6806 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6807 }
6808
6809 return true;
6810
6811 } catch (Exception $e) {
6812 $regular_response = $this->mxchat_generate_response_openrouter(
6813 $selected_model,
6814 $openrouter_api_key,
6815 $conversation_history,
6816 $relevant_content
6817 );
6818
6819 $response_data = [
6820 'text' => $regular_response,
6821 'html' => '',
6822 'session_id' => $session_id
6823 ];
6824
6825 if ($testing_data !== null) {
6826 $response_data['testing_data'] = $testing_data;
6827 }
6828
6829 header('Content-Type: application/json');
6830 echo json_encode($response_data);
6831 return true;
6832 }
6833 }
6834 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6835 try {
6836 $bot_id = $this->get_current_bot_id($session_id);
6837
6838 // Get system prompt instructions using centralized function
6839 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6840
6841 // Ensure conversation_history is an array
6842 if (!is_array($conversation_history)) {
6843 $conversation_history = array();
6844 }
6845
6846 // Format conversation history for OpenAI
6847 $formatted_conversation = array();
6848
6849 $formatted_conversation[] = array(
6850 'role' => 'system',
6851 'content' => $system_prompt_instructions . " " . $relevant_content
6852 );
6853
6854 foreach ($conversation_history as $message) {
6855 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6856 $role = $message['role'];
6857 if ($role === 'bot' || $role === 'agent') {
6858 $role = 'assistant';
6859 }
6860 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
6861 $role = 'user';
6862 }
6863 $formatted_conversation[] = array(
6864 'role' => $role,
6865 'content' => $message['content']
6866 );
6867 }
6868 }
6869
6870 // Check if we can actually stream
6871 if (headers_sent() || !function_exists('curl_init')) {
6872 // Fallback to regular response with testing data
6873 $regular_response = $this->mxchat_generate_response_openai(
6874 $selected_model,
6875 $api_key,
6876 $conversation_history,
6877 $relevant_content
6878 );
6879
6880 // Save bot response to transcript
6881 if (!empty($regular_response) && !empty($session_id)) {
6882 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6883 }
6884
6885 $response_data = [
6886 'text' => $regular_response,
6887 'html' => '',
6888 'session_id' => $session_id
6889 ];
6890
6891 if ($testing_data !== null) {
6892 $response_data['testing_data'] = $testing_data;
6893 }
6894
6895 header('Content-Type: application/json');
6896 echo json_encode($response_data);
6897 return true;
6898 }
6899
6900 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
6901 $is_gpt5_model = (
6902 strpos($selected_model, 'gpt-5') === 0 ||
6903 $selected_model === 'gpt-5.2' ||
6904 $selected_model === 'gpt-5.1-2025-11-13' ||
6905 $selected_model === 'gpt-5' ||
6906 $selected_model === 'gpt-5-mini' ||
6907 $selected_model === 'gpt-5-nano'
6908 );
6909
6910 // Build request body with optimal settings for fast streaming
6911 $request_body = [
6912 'model' => $selected_model,
6913 'messages' => $formatted_conversation,
6914 'temperature' => 1,
6915 'stream' => true
6916 ];
6917
6918 // Add reasoning_effort only for GPT-5 models that support it
6919 // These chat models don't support reasoning_effort parameter
6920 $no_reasoning_models = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
6921 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
6922 // GPT-5.1 uses 'low' instead of 'minimal'
6923 if ($selected_model === 'gpt-5.1-2025-11-13') {
6924 $request_body['reasoning_effort'] = 'low';
6925 } elseif ($selected_model === 'gpt-5.4') {
6926 $request_body['reasoning_effort'] = 'none';
6927 } else {
6928 $request_body['reasoning_effort'] = 'minimal';
6929 }
6930 }
6931
6932 $body = json_encode($request_body);
6933
6934 // Setup streaming headers now that we know we're actually streaming
6935 $this->setup_streaming_headers();
6936
6937 // Use cURL for streaming support
6938 $ch = curl_init();
6939 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
6940 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6941 curl_setopt($ch, CURLOPT_POST, true);
6942 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6943 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6944 'Content-Type: application/json',
6945 'Authorization: Bearer ' . $api_key
6946 ));
6947 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6948 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6949
6950 $full_response = ''; // Accumulate full response for saving
6951 $stream_started = false;
6952 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
6953
6954 // Buffer control for real-time streaming
6955 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6956 // Send testing data as the first event if available
6957 if (!$stream_started && $testing_data !== null) {
6958 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6959 flush();
6960 $stream_started = true;
6961 }
6962
6963 // CRITICAL FIX: Append new data to buffer
6964 $buffer .= $data;
6965
6966 // Process complete lines only
6967 $lines = explode("\n", $buffer);
6968
6969 // CRITICAL FIX: Keep the last incomplete line in the buffer
6970 // The last element might be incomplete, so keep it in buffer
6971 $buffer = array_pop($lines);
6972
6973 foreach ($lines as $line) {
6974 // Skip empty lines
6975 if (trim($line) === '') {
6976 continue;
6977 }
6978
6979 // Only process lines that start with "data: "
6980 if (strpos($line, 'data: ') !== 0) {
6981 continue;
6982 }
6983
6984 $json_str = substr($line, 6); // Remove 'data: ' prefix
6985
6986 if (trim($json_str) === '[DONE]') {
6987 echo "data: [DONE]\n\n";
6988 flush();
6989 continue;
6990 }
6991
6992 // Try to decode JSON
6993 $json = json_decode(trim($json_str), true);
6994 if ($json && isset($json['choices'][0]['delta']['content'])) {
6995 $content = $json['choices'][0]['delta']['content'];
6996 $full_response .= $content; // Accumulate the full response
6997
6998 // Send as SSE format
6999 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7000 flush();
7001 }
7002 }
7003
7004 return strlen($data);
7005 });
7006
7007 $response = curl_exec($ch);
7008 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7009
7010 if (curl_errno($ch) || $http_code !== 200) {
7011 $curl_error = curl_error($ch);
7012 curl_close($ch);
7013
7014 // Fallback to regular response
7015 $regular_response = $this->mxchat_generate_response_openai(
7016 $selected_model,
7017 $api_key,
7018 $conversation_history,
7019 $relevant_content
7020 );
7021
7022 // FIXED: Check if regular response returned an error
7023 if (is_array($regular_response) && isset($regular_response['error'])) {
7024 // Send error in SSE format since we're in streaming mode
7025 echo "data: " . json_encode([
7026 'error' => true,
7027 'error_message' => $regular_response['error'],
7028 'error_code' => $regular_response['error_code'] ?? 'api_error',
7029 'text' => $regular_response['error'],
7030 'message' => $regular_response['error']
7031 ]) . "\n\n";
7032 echo "data: [DONE]\n\n";
7033 flush();
7034 return true;
7035 }
7036
7037 $response_data = [
7038 'text' => $regular_response,
7039 'html' => '',
7040 'session_id' => $session_id
7041 ];
7042
7043 if ($testing_data !== null) {
7044 $response_data['testing_data'] = $testing_data;
7045 }
7046
7047 header('Content-Type: application/json');
7048 echo json_encode($response_data);
7049 return true;
7050 }
7051
7052 curl_close($ch);
7053
7054 // Save the complete response to maintain chat persistence
7055 if (!empty($full_response) && !empty($session_id)) {
7056 // Prepare RAG context for streaming response
7057 $rag_context_for_storage = null;
7058 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7059 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7060
7061 if ($has_rag_data || $has_action_data) {
7062 $rag_context_for_storage = [];
7063
7064 if ($has_rag_data) {
7065 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7066 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7067 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7068 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7069 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7070 }
7071
7072 if ($has_action_data) {
7073 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7074 }
7075 }
7076 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7077 }
7078
7079 return true; // Indicate streaming completed successfully
7080
7081 } catch (Exception $e) {
7082 // Fallback to regular response
7083 $regular_response = $this->mxchat_generate_response_openai(
7084 $selected_model,
7085 $api_key,
7086 $conversation_history,
7087 $relevant_content
7088 );
7089
7090 // FIXED: Check if regular response returned an error
7091 if (is_array($regular_response) && isset($regular_response['error'])) {
7092 // Send error in SSE format since we're in streaming mode
7093 echo "data: " . json_encode([
7094 'error' => true,
7095 'error_message' => $regular_response['error'],
7096 'error_code' => $regular_response['error_code'] ?? 'api_error',
7097 'text' => $regular_response['error'],
7098 'message' => $regular_response['error']
7099 ]) . "\n\n";
7100 echo "data: [DONE]\n\n";
7101 flush();
7102 return true;
7103 }
7104
7105 $response_data = [
7106 'text' => $regular_response,
7107 'html' => '',
7108 'session_id' => $session_id
7109 ];
7110
7111 if ($testing_data !== null) {
7112 $response_data['testing_data'] = $testing_data;
7113 }
7114
7115 header('Content-Type: application/json');
7116 echo json_encode($response_data);
7117 return true;
7118 }
7119 }
7120
7121 /**
7122 * Generate response using OpenAI Responses API with web search tool
7123 * This uses the newer Responses API which supports web search functionality
7124 */
7125 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
7126 try {
7127 $bot_id = $this->get_current_bot_id($session_id);
7128 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7129
7130 if (!is_array($conversation_history)) {
7131 $conversation_history = array();
7132 }
7133
7134 // Build the input for Responses API
7135 // The Responses API uses a different format - we need to construct the input properly
7136 $input_parts = [];
7137
7138 // Add system instructions as context
7139 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
7140
7141 // Build conversation as input items for Responses API
7142 foreach ($conversation_history as $message) {
7143 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7144 $role = $message['role'];
7145 if ($role === 'bot' || $role === 'agent') {
7146 $role = 'assistant';
7147 }
7148 if (!in_array($role, ['assistant', 'user'])) {
7149 $role = 'user';
7150 }
7151 $input_parts[] = [
7152 'type' => 'message',
7153 'role' => $role,
7154 'content' => $message['content']
7155 ];
7156 }
7157 }
7158
7159 // Build request body for Responses API
7160 $request_body = [
7161 'model' => $selected_model,
7162 'input' => $input_parts,
7163 'instructions' => $system_context,
7164 'stream' => $streaming
7165 ];
7166
7167 // Only add web search tool if web search is enabled in settings
7168 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
7169 if ($web_search_enabled) {
7170 $request_body['tools'] = [
7171 ['type' => 'web_search']
7172 ];
7173 }
7174
7175 // Add reasoning effort for supported models
7176 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
7177 $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
7178 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_web, true)) {
7179 if ($selected_model === 'gpt-5.1-2025-11-13') {
7180 $request_body['reasoning'] = ['effort' => 'low'];
7181 } elseif ($selected_model === 'gpt-5.4') {
7182 $request_body['reasoning'] = ['effort' => 'low'];
7183 }
7184 }
7185
7186 //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
7187
7188 if ($streaming) {
7189 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
7190 } else {
7191 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7192 }
7193
7194 } catch (Exception $e) {
7195 //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
7196 return [
7197 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
7198 'error_code' => 'web_search_exception'
7199 ];
7200 }
7201 }
7202
7203 /**
7204 * Handle non-streaming web search response
7205 */
7206 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7207 $request_body['stream'] = false;
7208
7209 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
7210 'headers' => array(
7211 'Authorization' => 'Bearer ' . $api_key,
7212 'Content-Type' => 'application/json'
7213 ),
7214 'body' => json_encode($request_body),
7215 'timeout' => 90
7216 ));
7217
7218 if (is_wp_error($response)) {
7219 //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
7220 return [
7221 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
7222 'error_code' => 'web_search_connection_error'
7223 ];
7224 }
7225
7226 $response_code = wp_remote_retrieve_response_code($response);
7227 $response_body = wp_remote_retrieve_body($response);
7228
7229 //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
7230 //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
7231
7232 if ($response_code !== 200) {
7233 $error_data = json_decode($response_body, true);
7234 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
7235 return [
7236 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
7237 'error_code' => 'web_search_api_error'
7238 ];
7239 }
7240
7241 $result = json_decode($response_body, true);
7242
7243 if (json_last_error() !== JSON_ERROR_NONE) {
7244 return [
7245 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
7246 'error_code' => 'web_search_json_error'
7247 ];
7248 }
7249
7250 // Extract the response text and citations from Responses API format
7251 $output_text = '';
7252 $citations = [];
7253
7254 if (isset($result['output'])) {
7255 foreach ($result['output'] as $output_item) {
7256 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
7257 foreach ($output_item['content'] as $content_item) {
7258 if ($content_item['type'] === 'output_text') {
7259 $output_text .= $content_item['text'];
7260
7261 // Extract citations/annotations
7262 if (isset($content_item['annotations'])) {
7263 foreach ($content_item['annotations'] as $annotation) {
7264 if ($annotation['type'] === 'url_citation') {
7265 $citations[] = [
7266 'url' => $annotation['url'],
7267 'title' => $annotation['title'] ?? ''
7268 ];
7269 }
7270 }
7271 }
7272 }
7273 }
7274 }
7275 }
7276 }
7277
7278 // If we have citations, append them to the response
7279 if (!empty($citations)) {
7280 $output_text .= "\n\n**Sources:**\n";
7281 $seen_urls = [];
7282 foreach ($citations as $citation) {
7283 if (!in_array($citation['url'], $seen_urls)) {
7284 $seen_urls[] = $citation['url'];
7285 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7286 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7287 }
7288 }
7289 }
7290
7291 // Save to transcript
7292 if (!empty($output_text) && !empty($session_id)) {
7293 $this->mxchat_save_chat_message($session_id, 'bot', $output_text);
7294 }
7295
7296 return $output_text;
7297 }
7298
7299 /**
7300 * Handle streaming web search response using Responses API
7301 */
7302 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7303 $request_body['stream'] = true;
7304
7305 // Check if we can stream
7306 if (headers_sent() || !function_exists('curl_init')) {
7307 // Fallback to non-streaming
7308 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7309 }
7310
7311 // Setup streaming headers
7312 $this->setup_streaming_headers();
7313
7314 $ch = curl_init();
7315 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
7316 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7317 curl_setopt($ch, CURLOPT_POST, true);
7318 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
7319 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7320 'Content-Type: application/json',
7321 'Authorization: Bearer ' . $api_key
7322 ));
7323 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7324 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
7325
7326 $full_response = '';
7327 $stream_started = false;
7328 $buffer = '';
7329 $citations = [];
7330
7331 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
7332 // Send testing data as first event if available
7333 if (!$stream_started && $testing_data !== null) {
7334 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7335 flush();
7336 $stream_started = true;
7337 }
7338
7339 $buffer .= $data;
7340 $lines = explode("\n", $buffer);
7341 $buffer = array_pop($lines);
7342
7343 foreach ($lines as $line) {
7344 if (trim($line) === '') continue;
7345 if (strpos($line, 'data: ') !== 0) continue;
7346
7347 $json_str = substr($line, 6);
7348
7349 if (trim($json_str) === '[DONE]') {
7350 // Append citations if we have any
7351 if (!empty($citations)) {
7352 $citation_text = "\n\n**Sources:**\n";
7353 $seen_urls = [];
7354 foreach ($citations as $citation) {
7355 if (!in_array($citation['url'], $seen_urls)) {
7356 $seen_urls[] = $citation['url'];
7357 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7358 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7359 }
7360 }
7361 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
7362 $full_response .= $citation_text;
7363 flush();
7364 }
7365 echo "data: [DONE]\n\n";
7366 flush();
7367 continue;
7368 }
7369
7370 $json = json_decode(trim($json_str), true);
7371 if (!$json) continue;
7372
7373 // Handle Responses API streaming events
7374 // The format is different from Chat Completions
7375 if (isset($json['type'])) {
7376 switch ($json['type']) {
7377 case 'response.output_text.delta':
7378 // Text content delta
7379 if (isset($json['delta'])) {
7380 $content = $json['delta'];
7381 $full_response .= $content;
7382 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7383 flush();
7384 }
7385 break;
7386
7387 case 'response.output_item.done':
7388 // Check for citations in completed items
7389 if (isset($json['item']['content'])) {
7390 foreach ($json['item']['content'] as $content_item) {
7391 if (isset($content_item['annotations'])) {
7392 foreach ($content_item['annotations'] as $annotation) {
7393 if ($annotation['type'] === 'url_citation') {
7394 $citations[] = [
7395 'url' => $annotation['url'],
7396 'title' => $annotation['title'] ?? ''
7397 ];
7398 }
7399 }
7400 }
7401 }
7402 }
7403 break;
7404 }
7405 }
7406 }
7407
7408 return strlen($data);
7409 });
7410
7411 $response = curl_exec($ch);
7412 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7413
7414 if (curl_errno($ch) || $http_code !== 200) {
7415 $curl_error = curl_error($ch);
7416 curl_close($ch);
7417
7418 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
7419
7420 // Fallback to non-streaming
7421 $fallback_response = $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7422
7423 if (is_array($fallback_response) && isset($fallback_response['error'])) {
7424 echo "data: " . json_encode([
7425 'error' => true,
7426 'error_message' => $fallback_response['error'],
7427 'error_code' => $fallback_response['error_code'] ?? 'web_search_error'
7428 ]) . "\n\n";
7429 echo "data: [DONE]\n\n";
7430 flush();
7431 return true;
7432 }
7433
7434 $response_data = [
7435 'text' => $fallback_response,
7436 'html' => '',
7437 'session_id' => $session_id
7438 ];
7439 if ($testing_data !== null) {
7440 $response_data['testing_data'] = $testing_data;
7441 }
7442 header('Content-Type: application/json');
7443 echo json_encode($response_data);
7444 return true;
7445 }
7446
7447 curl_close($ch);
7448
7449 // Save the complete response
7450 if (!empty($full_response) && !empty($session_id)) {
7451 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
7452 }
7453
7454 return true;
7455 }
7456
7457 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7458 try {
7459 // Get bot ID from session or request
7460 $bot_id = $this->get_current_bot_id($session_id);
7461
7462 // Get system prompt instructions using centralized function
7463 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7464 // Ensure conversation_history is an array
7465 if (!is_array($conversation_history)) {
7466 $conversation_history = array();
7467 }
7468
7469 // Clean and validate conversation history
7470 foreach ($conversation_history as &$message) {
7471 // Convert bot and agent roles to assistant
7472 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7473 $message['role'] = 'assistant';
7474 }
7475
7476 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7477 if (!in_array($message['role'], ['assistant', 'user'])) {
7478 $message['role'] = 'user';
7479 }
7480
7481 // Ensure content field exists
7482 if (!isset($message['content']) || empty($message['content'])) {
7483 $message['content'] = '';
7484 }
7485
7486 // Remove any unsupported fields
7487 $message = array_intersect_key($message, array_flip(['role', 'content']));
7488 }
7489
7490 // Add relevant content as the latest user message
7491 $conversation_history[] = [
7492 'role' => 'user',
7493 'content' => $relevant_content
7494 ];
7495
7496 // Prepare the request body with stream: true
7497 $body = json_encode([
7498 'model' => $selected_model,
7499 'messages' => $conversation_history,
7500 'max_tokens' => 1000,
7501 'temperature' => 0.8,
7502 'system' => $system_prompt_instructions,
7503 'stream' => true
7504 ]);
7505
7506 // Check if we can actually stream (headers not sent, etc.)
7507 if (headers_sent() || !function_exists('curl_init')) {
7508 // Fallback to regular response with testing data
7509 //error_log("MxChat: Streaming not possible, falling back to regular response");
7510 $regular_response = $this->mxchat_generate_response_claude(
7511 $selected_model,
7512 $claude_api_key,
7513 array_slice($conversation_history, 0, -1), // Remove the added content
7514 $relevant_content
7515 );
7516
7517 // Save bot response to transcript
7518 if (!empty($regular_response) && !empty($session_id)) {
7519 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7520 }
7521
7522 // Return as JSON with testing data
7523 $response_data = [
7524 'text' => $regular_response,
7525 'html' => '',
7526 'session_id' => $session_id
7527 ];
7528
7529 if ($testing_data !== null) {
7530 $response_data['testing_data'] = $testing_data;
7531 //error_log("MxChat Testing: Added testing data to Claude fallback response");
7532 }
7533
7534 // Clear any streaming headers and send JSON
7535 if (headers_sent() === false) {
7536 header('Content-Type: application/json');
7537 }
7538 echo json_encode($response_data);
7539 return true; // Indicate we handled the response
7540 }
7541
7542 // Setup streaming headers now that we know we're actually streaming
7543 $this->setup_streaming_headers();
7544
7545 // Use cURL for streaming support
7546 $ch = curl_init();
7547 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
7548 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7549 curl_setopt($ch, CURLOPT_POST, true);
7550 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7551 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7552 'Content-Type: application/json',
7553 'x-api-key: ' . $claude_api_key,
7554 'anthropic-version: 2023-06-01'
7555 ));
7556 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7557 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7558
7559 $full_response = ''; // Accumulate full response for saving
7560 $stream_started = false;
7561 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7562
7563 // Buffer control for real-time streaming
7564 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7565 // Send testing data as the first event if available
7566 if (!$stream_started && $testing_data !== null) {
7567 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7568 flush();
7569 $stream_started = true;
7570 //error_log("MxChat Testing: Sent testing data in Claude stream");
7571 }
7572
7573 // CRITICAL FIX: Append new data to buffer
7574 $buffer .= $data;
7575
7576 // Process complete lines only
7577 $lines = explode("\n", $buffer);
7578
7579 // CRITICAL FIX: Keep the last incomplete line in the buffer
7580 // The last element might be incomplete, so keep it in buffer
7581 $buffer = array_pop($lines);
7582
7583 foreach ($lines as $line) {
7584 if (trim($line) === '') {
7585 continue;
7586 }
7587
7588 // Claude uses event: and data: format
7589 if (strpos($line, 'event: ') === 0) {
7590 // Store the event type for the next data line
7591 continue;
7592 }
7593
7594 if (strpos($line, 'data: ') === 0) {
7595 $json_str = substr($line, 6); // Remove 'data: ' prefix
7596
7597 $json = json_decode(trim($json_str), true);
7598 if (json_last_error() !== JSON_ERROR_NONE) {
7599 continue;
7600 }
7601
7602 // Handle different event types
7603 if (isset($json['type'])) {
7604 switch ($json['type']) {
7605 case 'content_block_delta':
7606 if (isset($json['delta']['text'])) {
7607 $content = $json['delta']['text'];
7608 $full_response .= $content; // Accumulate
7609 // Send as SSE format compatible with your frontend
7610 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7611 flush();
7612 }
7613 break;
7614
7615 case 'message_stop':
7616 echo "data: [DONE]\n\n";
7617 flush();
7618 break;
7619
7620 case 'error':
7621 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
7622 flush();
7623 break;
7624 }
7625 }
7626 }
7627 }
7628
7629 return strlen($data);
7630 });
7631
7632 $response = curl_exec($ch);
7633 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7634
7635 if (curl_errno($ch)) {
7636 curl_close($ch);
7637 throw new Exception('cURL Error: ' . curl_error($ch));
7638 }
7639
7640 curl_close($ch);
7641
7642 if ($http_code !== 200) {
7643 // Fallback to regular response
7644 //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
7645 $regular_response = $this->mxchat_generate_response_claude(
7646 $selected_model,
7647 $claude_api_key,
7648 array_slice($conversation_history, 0, -1), // Remove the added content
7649 $relevant_content
7650 );
7651
7652 // FIXED: Check if regular response returned an error
7653 if (is_array($regular_response) && isset($regular_response['error'])) {
7654 // Send error in SSE format since we're in streaming mode
7655 echo "data: " . json_encode([
7656 'error' => true,
7657 'error_message' => $regular_response['error'],
7658 'error_code' => $regular_response['error_code'] ?? 'api_error',
7659 'text' => $regular_response['error'],
7660 'message' => $regular_response['error']
7661 ]) . "\n\n";
7662 echo "data: [DONE]\n\n";
7663 flush();
7664 return true;
7665 }
7666
7667 $response_data = [
7668 'text' => $regular_response,
7669 'html' => '',
7670 'session_id' => $session_id
7671 ];
7672
7673 if ($testing_data !== null) {
7674 $response_data['testing_data'] = $testing_data;
7675 //error_log("MxChat Testing: Added testing data to Claude error fallback");
7676 }
7677
7678 header('Content-Type: application/json');
7679 echo json_encode($response_data);
7680 return true;
7681 }
7682
7683 // Save the complete response to maintain chat persistence
7684 if (!empty($full_response) && !empty($session_id)) {
7685 // Prepare RAG context for streaming response
7686 $rag_context_for_storage = null;
7687 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7688 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7689
7690 if ($has_rag_data || $has_action_data) {
7691 $rag_context_for_storage = [];
7692
7693 if ($has_rag_data) {
7694 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7695 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7696 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7697 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7698 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7699 }
7700
7701 if ($has_action_data) {
7702 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7703 }
7704 }
7705 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7706 }
7707
7708 return true; // Indicate streaming completed successfully
7709
7710 } catch (Exception $e) {
7711 //error_log("MxChat Claude streaming exception: " . $e->getMessage());
7712
7713 // Fallback to regular response on exception
7714 $regular_response = $this->mxchat_generate_response_claude(
7715 $selected_model,
7716 $claude_api_key,
7717 $conversation_history,
7718 $relevant_content
7719 );
7720
7721 // FIXED: Check if regular response returned an error
7722 if (is_array($regular_response) && isset($regular_response['error'])) {
7723 // Send error in SSE format since we're in streaming mode
7724 echo "data: " . json_encode([
7725 'error' => true,
7726 'error_message' => $regular_response['error'],
7727 'error_code' => $regular_response['error_code'] ?? 'api_error',
7728 'text' => $regular_response['error'],
7729 'message' => $regular_response['error']
7730 ]) . "\n\n";
7731 echo "data: [DONE]\n\n";
7732 flush();
7733 return true;
7734 }
7735
7736 $response_data = [
7737 'text' => $regular_response,
7738 'html' => '',
7739 'session_id' => $session_id
7740 ];
7741
7742 if ($testing_data !== null) {
7743 $response_data['testing_data'] = $testing_data;
7744 //error_log("MxChat Testing: Added testing data to Claude exception fallback");
7745 }
7746
7747 header('Content-Type: application/json');
7748 echo json_encode($response_data);
7749 return true;
7750 }
7751 }
7752 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7753 try {
7754 // Get bot ID from session or request
7755 $bot_id = $this->get_current_bot_id($session_id);
7756
7757 // Get system prompt instructions using centralized function
7758 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7759
7760 // Ensure conversation_history is an array
7761 if (!is_array($conversation_history)) {
7762 $conversation_history = array();
7763 }
7764
7765 // Format conversation history for X.AI (same as OpenAI format)
7766 $formatted_conversation = array();
7767
7768 $formatted_conversation[] = array(
7769 'role' => 'system',
7770 'content' => $system_prompt_instructions . " " . $relevant_content
7771 );
7772
7773 foreach ($conversation_history as $message) {
7774 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7775 $role = $message['role'];
7776 if ($role === 'bot' || $role === 'agent') {
7777 $role = 'assistant';
7778 }
7779 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7780 $role = 'user';
7781 }
7782 $formatted_conversation[] = array(
7783 'role' => $role,
7784 'content' => $message['content']
7785 );
7786 }
7787 }
7788
7789 // Check if we can actually stream
7790 if (headers_sent() || !function_exists('curl_init')) {
7791 // Fallback to regular response with testing data
7792 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
7793 $regular_response = $this->mxchat_generate_response_xai(
7794 $selected_model,
7795 $xai_api_key,
7796 $conversation_history,
7797 $relevant_content
7798 );
7799
7800 // Save bot response to transcript
7801 if (!empty($regular_response) && !empty($session_id)) {
7802 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7803 }
7804
7805 $response_data = [
7806 'text' => $regular_response,
7807 'html' => '',
7808 'session_id' => $session_id
7809 ];
7810
7811 if ($testing_data !== null) {
7812 $response_data['testing_data'] = $testing_data;
7813 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
7814 }
7815
7816 header('Content-Type: application/json');
7817 echo json_encode($response_data);
7818 return true;
7819 }
7820
7821 // Prepare the request body with stream: true
7822 $body = json_encode([
7823 'model' => $selected_model,
7824 'messages' => $formatted_conversation,
7825 'temperature' => 0.8,
7826 'stream' => true
7827 ]);
7828
7829 // Setup streaming headers now that we know we're actually streaming
7830 $this->setup_streaming_headers();
7831
7832 // Use cURL for streaming support
7833 $ch = curl_init();
7834 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
7835 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7836 curl_setopt($ch, CURLOPT_POST, true);
7837 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7838 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7839 'Content-Type: application/json',
7840 'Authorization: Bearer ' . $xai_api_key
7841 ));
7842 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7843 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7844
7845 $full_response = ''; // Accumulate full response for saving
7846 $stream_started = false;
7847 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7848
7849 // Buffer control for real-time streaming
7850 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7851 // Send testing data as the first event if available
7852 if (!$stream_started && $testing_data !== null) {
7853 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7854 flush();
7855 $stream_started = true;
7856 //error_log("MxChat Testing: Sent testing data in X.AI stream");
7857 }
7858
7859 // CRITICAL FIX: Append new data to buffer
7860 $buffer .= $data;
7861
7862 // Process complete lines only
7863 $lines = explode("\n", $buffer);
7864
7865 // CRITICAL FIX: Keep the last incomplete line in the buffer
7866 // The last element might be incomplete, so keep it in buffer
7867 $buffer = array_pop($lines);
7868
7869 foreach ($lines as $line) {
7870 // Skip empty lines
7871 if (trim($line) === '') {
7872 continue;
7873 }
7874
7875 // Only process lines that start with "data: "
7876 if (strpos($line, 'data: ') !== 0) {
7877 continue;
7878 }
7879
7880 $json_str = substr($line, 6); // Remove 'data: ' prefix
7881
7882 if (trim($json_str) === '[DONE]') {
7883 echo "data: [DONE]\n\n";
7884 flush();
7885 continue;
7886 }
7887
7888 // Try to decode JSON
7889 $json = json_decode(trim($json_str), true);
7890 if ($json && isset($json['choices'][0]['delta']['content'])) {
7891 $content = $json['choices'][0]['delta']['content'];
7892 $full_response .= $content; // Accumulate
7893 // Send as SSE format
7894 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7895 flush();
7896 }
7897 }
7898
7899 return strlen($data);
7900 });
7901
7902 $response = curl_exec($ch);
7903 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7904
7905 if (curl_errno($ch) || $http_code !== 200) {
7906 curl_close($ch);
7907
7908 // Fallback to regular response
7909 //error_log("MxChat: X.AI streaming failed, falling back");
7910 $regular_response = $this->mxchat_generate_response_xai(
7911 $selected_model,
7912 $xai_api_key,
7913 $conversation_history,
7914 $relevant_content
7915 );
7916
7917 $response_data = [
7918 'text' => $regular_response,
7919 'html' => '',
7920 'session_id' => $session_id
7921 ];
7922
7923 if ($testing_data !== null) {
7924 $response_data['testing_data'] = $testing_data;
7925 //error_log("MxChat Testing: Added testing data to X.AI error fallback");
7926 }
7927
7928 header('Content-Type: application/json');
7929 echo json_encode($response_data);
7930 return true;
7931 }
7932
7933 curl_close($ch);
7934
7935 // Save the complete response to maintain chat persistence
7936 if (!empty($full_response) && !empty($session_id)) {
7937 // Prepare RAG context for streaming response
7938 $rag_context_for_storage = null;
7939 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7940 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7941
7942 if ($has_rag_data || $has_action_data) {
7943 $rag_context_for_storage = [];
7944
7945 if ($has_rag_data) {
7946 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7947 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7948 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7949 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7950 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7951 }
7952
7953 if ($has_action_data) {
7954 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7955 }
7956 }
7957 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7958 }
7959
7960 return true; // Indicate streaming completed successfully
7961
7962 } catch (Exception $e) {
7963 //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
7964
7965 // Fallback to regular response
7966 $regular_response = $this->mxchat_generate_response_xai(
7967 $selected_model,
7968 $xai_api_key,
7969 $conversation_history,
7970 $relevant_content
7971 );
7972
7973 $response_data = [
7974 'text' => $regular_response,
7975 'html' => '',
7976 'session_id' => $session_id
7977 ];
7978
7979 if ($testing_data !== null) {
7980 $response_data['testing_data'] = $testing_data;
7981 //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
7982 }
7983
7984 header('Content-Type: application/json');
7985 echo json_encode($response_data);
7986 return true;
7987 }
7988 }
7989 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7990 try {
7991 // Get bot ID from session or request
7992 $bot_id = $this->get_current_bot_id($session_id);
7993
7994 // Get system prompt instructions using centralized function
7995 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7996
7997 // Ensure conversation_history is an array
7998 if (!is_array($conversation_history)) {
7999 $conversation_history = array();
8000 }
8001
8002 // Format conversation history for DeepSeek
8003 $formatted_conversation = array();
8004
8005 $formatted_conversation[] = array(
8006 'role' => 'system',
8007 'content' => $system_prompt_instructions . " " . $relevant_content
8008 );
8009
8010 foreach ($conversation_history as $message) {
8011 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8012 $role = $message['role'];
8013 if ($role === 'bot' || $role === 'agent') {
8014 $role = 'assistant';
8015 }
8016 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8017 $role = 'user';
8018 }
8019 $formatted_conversation[] = array(
8020 'role' => $role,
8021 'content' => $message['content']
8022 );
8023 }
8024 }
8025
8026 // Check if we can actually stream
8027 if (headers_sent() || !function_exists('curl_init')) {
8028 // Fallback to regular response with testing data
8029 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
8030 $regular_response = $this->mxchat_generate_response_deepseek(
8031 $selected_model,
8032 $deepseek_api_key,
8033 $conversation_history,
8034 $relevant_content
8035 );
8036
8037 // Save bot response to transcript
8038 if (!empty($regular_response) && !empty($session_id)) {
8039 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8040 }
8041
8042 $response_data = [
8043 'text' => $regular_response,
8044 'html' => '',
8045 'session_id' => $session_id
8046 ];
8047
8048 if ($testing_data !== null) {
8049 $response_data['testing_data'] = $testing_data;
8050 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
8051 }
8052
8053 header('Content-Type: application/json');
8054 echo json_encode($response_data);
8055 return true;
8056 }
8057
8058 // Prepare the request body with stream: true
8059 $body = json_encode([
8060 'model' => $selected_model,
8061 'messages' => $formatted_conversation,
8062 'temperature' => 0.8,
8063 'stream' => true
8064 ]);
8065
8066 // Setup streaming headers now that we know we're actually streaming
8067 $this->setup_streaming_headers();
8068
8069 // Use cURL for streaming support
8070 $ch = curl_init();
8071 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
8072 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8073 curl_setopt($ch, CURLOPT_POST, true);
8074 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8075 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8076 'Content-Type: application/json',
8077 'Authorization: Bearer ' . $deepseek_api_key
8078 ));
8079 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8080 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8081
8082 $full_response = ''; // Accumulate full response for saving
8083 $stream_started = false;
8084 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
8085
8086 // Buffer control for real-time streaming
8087 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
8088 // Send testing data as the first event if available
8089 if (!$stream_started && $testing_data !== null) {
8090 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8091 flush();
8092 $stream_started = true;
8093 //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
8094 }
8095
8096 // CRITICAL FIX: Append new data to buffer
8097 $buffer .= $data;
8098
8099 // Process complete lines only
8100 $lines = explode("\n", $buffer);
8101
8102 // CRITICAL FIX: Keep the last incomplete line in the buffer
8103 // The last element might be incomplete, so keep it in buffer
8104 $buffer = array_pop($lines);
8105
8106 foreach ($lines as $line) {
8107 // Skip empty lines
8108 if (trim($line) === '') {
8109 continue;
8110 }
8111
8112 // Only process lines that start with "data: "
8113 if (strpos($line, 'data: ') !== 0) {
8114 continue;
8115 }
8116
8117 $json_str = substr($line, 6); // Remove 'data: ' prefix
8118
8119 if (trim($json_str) === '[DONE]') {
8120 echo "data: [DONE]\n\n";
8121 flush();
8122 continue;
8123 }
8124
8125 // Try to decode JSON
8126 $json = json_decode(trim($json_str), true);
8127 if ($json && isset($json['choices'][0]['delta']['content'])) {
8128 $content = $json['choices'][0]['delta']['content'];
8129 $full_response .= $content; // Accumulate the full response
8130
8131 // Send as SSE format
8132 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8133 flush();
8134 }
8135 }
8136
8137 return strlen($data);
8138 });
8139
8140 $response = curl_exec($ch);
8141 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8142
8143 if (curl_errno($ch) || $http_code !== 200) {
8144 $curl_error = curl_error($ch);
8145 curl_close($ch);
8146
8147 // Log the specific error for debugging
8148 //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
8149
8150 // Fallback to regular response
8151 $regular_response = $this->mxchat_generate_response_deepseek(
8152 $selected_model,
8153 $deepseek_api_key,
8154 $conversation_history,
8155 $relevant_content
8156 );
8157
8158 // Handle error response from regular function
8159 if (is_array($regular_response) && isset($regular_response['error'])) {
8160 if ($testing_data !== null) {
8161 $regular_response['testing_data'] = $testing_data;
8162 }
8163 header('Content-Type: application/json');
8164 echo json_encode($regular_response);
8165 return true;
8166 }
8167
8168 $response_data = [
8169 'text' => $regular_response,
8170 'html' => '',
8171 'session_id' => $session_id
8172 ];
8173
8174 if ($testing_data !== null) {
8175 $response_data['testing_data'] = $testing_data;
8176 //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
8177 }
8178
8179 header('Content-Type: application/json');
8180 echo json_encode($response_data);
8181 return true;
8182 }
8183
8184 curl_close($ch);
8185
8186 // Save the complete response to maintain chat persistence
8187 if (!empty($full_response) && !empty($session_id)) {
8188 // Prepare RAG context for streaming response
8189 $rag_context_for_storage = null;
8190 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8191 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8192
8193 if ($has_rag_data || $has_action_data) {
8194 $rag_context_for_storage = [];
8195
8196 if ($has_rag_data) {
8197 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8198 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8199 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8200 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8201 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8202 }
8203
8204 if ($has_action_data) {
8205 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8206 }
8207 }
8208 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8209 }
8210
8211 return true; // Indicate streaming completed successfully
8212
8213 } catch (Exception $e) {
8214 //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
8215
8216 // Fallback to regular response
8217 $regular_response = $this->mxchat_generate_response_deepseek(
8218 $selected_model,
8219 $deepseek_api_key,
8220 $conversation_history,
8221 $relevant_content
8222 );
8223
8224 // Handle error response from regular function
8225 if (is_array($regular_response) && isset($regular_response['error'])) {
8226 if ($testing_data !== null) {
8227 $regular_response['testing_data'] = $testing_data;
8228 }
8229 header('Content-Type: application/json');
8230 echo json_encode($regular_response);
8231 return true;
8232 }
8233
8234 $response_data = [
8235 'text' => $regular_response,
8236 'html' => '',
8237 'session_id' => $session_id
8238 ];
8239
8240 if ($testing_data !== null) {
8241 $response_data['testing_data'] = $testing_data;
8242 //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
8243 }
8244
8245 header('Content-Type: application/json');
8246 echo json_encode($response_data);
8247 return true;
8248 }
8249 }
8250
8251
8252 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
8253 try {
8254 if (!is_array($conversation_history)) {
8255 $conversation_history = array();
8256 }
8257
8258 $bot_id = $this->get_current_bot_id('');
8259 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8260
8261 $formatted_conversation = array();
8262
8263 $formatted_conversation[] = array(
8264 'role' => 'system',
8265 'content' => $system_prompt_instructions . " " . $relevant_content
8266 );
8267
8268 foreach ($conversation_history as $message) {
8269 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8270 $role = $message['role'];
8271
8272 if ($role === 'bot' || $role === 'agent') {
8273 $role = 'assistant';
8274 }
8275 if (!in_array($role, ['system', 'assistant', 'user'])) {
8276 $role = 'user';
8277 }
8278
8279 $formatted_conversation[] = array(
8280 'role' => $role,
8281 'content' => $message['content']
8282 );
8283 }
8284 }
8285
8286 $body = json_encode([
8287 'model' => $selected_model,
8288 'messages' => $formatted_conversation,
8289 'temperature' => 1,
8290 ]);
8291
8292 $args = [
8293 'body' => $body,
8294 'headers' => [
8295 'Content-Type' => 'application/json',
8296 'Authorization' => 'Bearer ' . $openrouter_api_key,
8297 'HTTP-Referer' => home_url(),
8298 'X-Title' => get_bloginfo('name'),
8299 ],
8300 'timeout' => 60,
8301 'redirection' => 5,
8302 'blocking' => true,
8303 'httpversion' => '1.0',
8304 'sslverify' => true,
8305 ];
8306
8307 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', $args);
8308
8309 if (is_wp_error($response)) {
8310 $error_message = $response->get_error_message();
8311 return [
8312 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
8313 'error_code' => 'openrouter_connection_error',
8314 'provider' => 'openrouter'
8315 ];
8316 }
8317
8318 $status_code = wp_remote_retrieve_response_code($response);
8319 if ($status_code !== 200) {
8320 $response_body = wp_remote_retrieve_body($response);
8321 $decoded_response = json_decode($response_body, true);
8322
8323 $error_message = isset($decoded_response['error']['message'])
8324 ? $decoded_response['error']['message']
8325 : 'HTTP Error ' . $status_code;
8326
8327 return [
8328 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
8329 'error_code' => 'openrouter_api_error',
8330 'provider' => 'openrouter',
8331 'status_code' => $status_code
8332 ];
8333 }
8334
8335 $response_body = wp_remote_retrieve_body($response);
8336 $decoded_response = json_decode($response_body, true);
8337
8338 if (isset($decoded_response['choices'][0]['message']['content'])) {
8339 return trim($decoded_response['choices'][0]['message']['content']);
8340 } else {
8341 return [
8342 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
8343 'error_code' => 'openrouter_response_format_error',
8344 'provider' => 'openrouter'
8345 ];
8346 }
8347 } catch (Exception $e) {
8348 return [
8349 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
8350 'error_code' => 'openrouter_exception',
8351 'provider' => 'openrouter'
8352 ];
8353 }
8354 }
8355 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
8356
8357 // Get bot ID from session or request
8358 $bot_id = $this->get_current_bot_id($session_id);
8359
8360 // Get system prompt instructions using centralized function
8361 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8362
8363 // Clean and validate conversation history
8364 foreach ($conversation_history as &$message) {
8365 // Convert bot and agent roles to assistant
8366 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
8367 $message['role'] = 'assistant';
8368 }
8369
8370 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
8371 if (!in_array($message['role'], ['assistant', 'user'])) {
8372 $message['role'] = 'user';
8373 }
8374
8375 // Ensure content field exists
8376 if (!isset($message['content']) || empty($message['content'])) {
8377 $message['content'] = '';
8378 }
8379
8380 // Remove any unsupported fields
8381 $message = array_intersect_key($message, array_flip(['role', 'content']));
8382 }
8383
8384 // Add relevant content as the latest user message
8385 $conversation_history[] = [
8386 'role' => 'user',
8387 'content' => $relevant_content
8388 ];
8389
8390 // Build request body
8391 $body = json_encode([
8392 'model' => $selected_model,
8393 'max_tokens' => 1000,
8394 'temperature' => 0.8,
8395 'messages' => $conversation_history,
8396 'system' => $system_prompt_instructions
8397 ]);
8398
8399 // Set up API request
8400 $args = [
8401 'body' => $body,
8402 'headers' => [
8403 'Content-Type' => 'application/json',
8404 'x-api-key' => $claude_api_key,
8405 'anthropic-version' => '2023-06-01'
8406 ],
8407 'timeout' => 60,
8408 'redirection' => 5,
8409 'blocking' => true,
8410 'httpversion' => '1.0',
8411 'sslverify' => true,
8412 ];
8413
8414 // Make API request
8415 $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
8416
8417 // Check for WordPress errors
8418 if (is_wp_error($response)) {
8419 //error_log("Claude API request error: " . $response->get_error_message());
8420 return "Sorry, there was an error connecting to the API.";
8421 }
8422
8423 // Check HTTP response code
8424 $http_code = wp_remote_retrieve_response_code($response);
8425 if ($http_code !== 200) {
8426 $error_body = wp_remote_retrieve_body($response);
8427 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
8428
8429 // Try to extract error message from response
8430 $error_data = json_decode($error_body, true);
8431 $error_message = isset($error_data['error']['message']) ?
8432 $error_data['error']['message'] :
8433 "HTTP error " . $http_code;
8434
8435 return "Sorry, the API returned an error: " . $error_message;
8436 }
8437
8438 // Parse response
8439 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8440
8441 // Check for JSON decode errors
8442 if (json_last_error() !== JSON_ERROR_NONE) {
8443 //error_log("Claude API JSON decode error: " . json_last_error_msg());
8444 return "Sorry, there was an error processing the API response.";
8445 }
8446
8447 // Extract and validate response content
8448 if (isset($response_body['content']) &&
8449 is_array($response_body['content']) &&
8450 !empty($response_body['content']) &&
8451 isset($response_body['content'][0]['text'])) {
8452 return trim($response_body['content'][0]['text']);
8453 }
8454
8455 // Log unexpected response format
8456 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
8457 return "Sorry, I received an unexpected response format from the API.";
8458 }
8459 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
8460 try {
8461 // Ensure conversation_history is an array
8462 if (!is_array($conversation_history)) {
8463 $conversation_history = array();
8464 }
8465
8466 // Get bot ID from session or request
8467 $bot_id = $this->get_current_bot_id('');
8468
8469 // Get system prompt instructions using centralized function
8470 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8471
8472 // Create a new array for the formatted conversation
8473 $formatted_conversation = array();
8474
8475 // Add system message first
8476 $formatted_conversation[] = array(
8477 'role' => 'system',
8478 'content' => $system_prompt_instructions . " " . $relevant_content
8479 );
8480
8481 // Add the rest of the conversation history
8482 foreach ($conversation_history as $message) {
8483 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8484 $role = $message['role'];
8485
8486 // Convert roles to supported format
8487 if ($role === 'bot' || $role === 'agent') {
8488 $role = 'assistant';
8489 }
8490 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8491 $role = 'user';
8492 }
8493
8494 $formatted_conversation[] = array(
8495 'role' => $role,
8496 'content' => $message['content']
8497 );
8498 }
8499 }
8500
8501 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8502 $is_gpt5_model = (
8503 strpos($selected_model, 'gpt-5') === 0 ||
8504 $selected_model === 'gpt-5.2' ||
8505 $selected_model === 'gpt-5.1-2025-11-13' ||
8506 $selected_model === 'gpt-5' ||
8507 $selected_model === 'gpt-5-mini' ||
8508 $selected_model === 'gpt-5-nano'
8509 );
8510
8511 // Build request body with optimal settings for fast responses
8512 $request_body = [
8513 'model' => $selected_model,
8514 'messages' => $formatted_conversation,
8515 'temperature' => 1,
8516 'stream' => false
8517 ];
8518
8519 // Add reasoning_effort only for GPT-5 models that support it
8520 // These chat models don't support reasoning_effort parameter
8521 $no_reasoning_models = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
8522 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
8523 // GPT-5.1 uses 'low' instead of 'minimal'
8524 if ($selected_model === 'gpt-5.1-2025-11-13') {
8525 $request_body['reasoning_effort'] = 'low';
8526 } elseif ($selected_model === 'gpt-5.4') {
8527 $request_body['reasoning_effort'] = 'none';
8528 } else {
8529 $request_body['reasoning_effort'] = 'minimal';
8530 }
8531 }
8532
8533 $body = json_encode($request_body);
8534
8535 $args = [
8536 'body' => $body,
8537 'headers' => [
8538 'Content-Type' => 'application/json',
8539 'Authorization' => 'Bearer ' . $api_key,
8540 ],
8541 'timeout' => 60,
8542 'redirection' => 5,
8543 'blocking' => true,
8544 'httpversion' => '1.0',
8545 'sslverify' => true,
8546 ];
8547
8548 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
8549
8550 if (is_wp_error($response)) {
8551 $error_message = $response->get_error_message();
8552 return [
8553 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
8554 'error_code' => 'openai_connection_error',
8555 'provider' => 'openai'
8556 ];
8557 }
8558
8559 $status_code = wp_remote_retrieve_response_code($response);
8560 if ($status_code !== 200) {
8561 $response_body = wp_remote_retrieve_body($response);
8562 $decoded_response = json_decode($response_body, true);
8563
8564 $error_message = isset($decoded_response['error']['message'])
8565 ? $decoded_response['error']['message']
8566 : 'HTTP Error ' . $status_code;
8567
8568 $error_type = isset($decoded_response['error']['type'])
8569 ? $decoded_response['error']['type']
8570 : 'unknown';
8571
8572 // Handle specific error types
8573 switch ($error_type) {
8574 case 'invalid_request_error':
8575 if (strpos($error_message, 'API key') !== false) {
8576 return [
8577 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
8578 'error_code' => 'openai_invalid_api_key',
8579 'provider' => 'openai'
8580 ];
8581 }
8582 break;
8583
8584 case 'authentication_error':
8585 return [
8586 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
8587 'error_code' => 'openai_auth_error',
8588 'provider' => 'openai'
8589 ];
8590
8591 case 'rate_limit_exceeded':
8592 return [
8593 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
8594 'error_code' => 'openai_rate_limit',
8595 'provider' => 'openai'
8596 ];
8597
8598 case 'quota_exceeded':
8599 return [
8600 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
8601 'error_code' => 'openai_quota_exceeded',
8602 'provider' => 'openai'
8603 ];
8604 }
8605
8606 // Generic error fallback
8607 return [
8608 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
8609 'error_code' => 'openai_api_error',
8610 'provider' => 'openai',
8611 'status_code' => $status_code
8612 ];
8613 }
8614
8615 $response_body = wp_remote_retrieve_body($response);
8616 $decoded_response = json_decode($response_body, true);
8617
8618 if (isset($decoded_response['choices'][0]['message']['content'])) {
8619 return trim($decoded_response['choices'][0]['message']['content']);
8620 } else {
8621 return [
8622 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
8623 'error_code' => 'openai_response_format_error',
8624 'provider' => 'openai'
8625 ];
8626 }
8627 } catch (Exception $e) {
8628 return [
8629 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
8630 'error_code' => 'openai_exception',
8631 'provider' => 'openai'
8632 ];
8633 }
8634 }
8635
8636 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
8637 try {
8638 // Get bot ID from session or request
8639 $bot_id = $this->get_current_bot_id($session_id);
8640
8641 // Get system prompt instructions using centralized function
8642 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8643
8644 // Add system prompt to relevant content
8645 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8646
8647 // Prepend system instructions to the conversation history
8648 array_unshift($conversation_history, [
8649 'role' => 'system',
8650 'content' => "Here are your instructions: " . $content_with_instructions
8651 ]);
8652
8653 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
8654 foreach ($conversation_history as &$message) {
8655 if ($message['role'] === 'bot') {
8656 $message['role'] = 'assistant';
8657 } elseif ($message['role'] === 'agent') {
8658 // Tag the message as coming from a live agent
8659 $message['role'] = 'assistant';
8660 if (!isset($message['metadata'])) {
8661 $message['metadata'] = ['source' => 'live_agent'];
8662 }
8663 }
8664
8665 // Ensure all roles are valid
8666 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
8667 $message['role'] = 'user'; // Default to 'user'
8668 }
8669 }
8670
8671 // Build the request body
8672 $body = json_encode([
8673 'model' => $selected_model,
8674 'messages' => $conversation_history,
8675 'temperature' => 0.8,
8676 'stream' => false
8677 ]);
8678
8679 // Set up the API request
8680 $args = [
8681 'body' => $body,
8682 'headers' => [
8683 'Content-Type' => 'application/json',
8684 'Authorization' => 'Bearer ' . $xai_api_key,
8685 ],
8686 'timeout' => 60,
8687 'redirection' => 5,
8688 'blocking' => true,
8689 'httpversion' => '1.0',
8690 'sslverify' => true,
8691 ];
8692
8693 // Make the API request
8694 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
8695
8696 // Process the response
8697 if (is_wp_error($response)) {
8698 $error_message = $response->get_error_message();
8699 //error_log('X.AI API Error: ' . $error_message);
8700 return [
8701 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
8702 'error_code' => 'xai_connection_error',
8703 'provider' => 'xai'
8704 ];
8705 }
8706
8707 $status_code = wp_remote_retrieve_response_code($response);
8708 if ($status_code !== 200) {
8709 $response_body = wp_remote_retrieve_body($response);
8710 $decoded_response = json_decode($response_body, true);
8711
8712 // Log the full response for debugging
8713 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
8714
8715 // Extract error message from X.AI's specific format
8716 $error_message = '';
8717
8718 // Check for direct error string (as seen in your logs)
8719 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
8720 $error_message = $decoded_response['error'];
8721 }
8722 // Check for nested error object (OpenAI style)
8723 elseif (isset($decoded_response['error']['message'])) {
8724 $error_message = $decoded_response['error']['message'];
8725 }
8726 // Check for top-level message
8727 elseif (isset($decoded_response['message'])) {
8728 $error_message = $decoded_response['message'];
8729 }
8730 // Fallback
8731 else {
8732 $error_message = 'HTTP Error ' . $status_code;
8733 }
8734
8735 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
8736
8737 // Check for API key errors using string matching
8738 if (stripos($error_message, 'api key') !== false ||
8739 stripos($error_message, 'incorrect api key') !== false ||
8740 stripos($error_message, 'invalid api key') !== false) {
8741 return [
8742 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
8743 'error_code' => 'xai_invalid_api_key',
8744 'provider' => 'xai'
8745 ];
8746 }
8747
8748 // Authentication errors
8749 if ($status_code === 401 || $status_code === 403 ||
8750 stripos($error_message, 'auth') !== false) {
8751 return [
8752 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
8753 'error_code' => 'xai_auth_error',
8754 'provider' => 'xai'
8755 ];
8756 }
8757
8758 // Model errors
8759 if (stripos($error_message, 'model') !== false) {
8760 return [
8761 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
8762 'error_code' => 'xai_invalid_model',
8763 'provider' => 'xai'
8764 ];
8765 }
8766
8767 // Rate limit errors
8768 if ($status_code === 429 ||
8769 stripos($error_message, 'rate') !== false ||
8770 stripos($error_message, 'limit') !== false) {
8771 return [
8772 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
8773 'error_code' => 'xai_rate_limit',
8774 'provider' => 'xai'
8775 ];
8776 }
8777
8778 // Quota errors
8779 if (stripos($error_message, 'quota') !== false ||
8780 stripos($error_message, 'billing') !== false) {
8781 return [
8782 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
8783 'error_code' => 'xai_quota_exceeded',
8784 'provider' => 'xai'
8785 ];
8786 }
8787
8788 // Server errors
8789 if ($status_code >= 500) {
8790 return [
8791 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
8792 'error_code' => 'xai_service_unavailable',
8793 'provider' => 'xai'
8794 ];
8795 }
8796
8797 // Generic error fallback with the actual error message
8798 return [
8799 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
8800 'error_code' => 'xai_api_error',
8801 'provider' => 'xai',
8802 'status_code' => $status_code
8803 ];
8804 }
8805
8806 $response_body = wp_remote_retrieve_body($response);
8807 $decoded_response = json_decode($response_body, true);
8808
8809 if (isset($decoded_response['choices'][0]['message']['content'])) {
8810 return trim($decoded_response['choices'][0]['message']['content']);
8811 } else {
8812 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
8813 return [
8814 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
8815 'error_code' => 'xai_response_format_error',
8816 'provider' => 'xai'
8817 ];
8818 }
8819 } catch (Exception $e) {
8820 //error_log('X.AI Exception: ' . $e->getMessage());
8821 return [
8822 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
8823 'error_code' => 'xai_exception',
8824 'provider' => 'xai'
8825 ];
8826 }
8827
8828
8829 }
8830 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
8831 try {
8832 // Ensure conversation_history is an array
8833 if (!is_array($conversation_history)) {
8834 $conversation_history = array();
8835 }
8836
8837 // Get bot ID from session or request
8838 $bot_id = $this->get_current_bot_id($session_id);
8839
8840 // Get system prompt instructions using centralized function
8841 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8842
8843 // Create a new array for the formatted conversation
8844 $formatted_conversation = array();
8845
8846 // Add system message first
8847 $formatted_conversation[] = array(
8848 'role' => 'system',
8849 'content' => $system_prompt_instructions . " " . $relevant_content
8850 );
8851
8852 // Add the rest of the conversation history
8853 foreach ($conversation_history as $message) {
8854 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8855 $role = $message['role'];
8856
8857 // Convert roles to supported format
8858 if ($role === 'bot' || $role === 'agent') {
8859 $role = 'assistant';
8860 }
8861 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8862 $role = 'user';
8863 }
8864
8865 $formatted_conversation[] = array(
8866 'role' => $role,
8867 'content' => $message['content']
8868 );
8869 }
8870 }
8871
8872 $body = json_encode([
8873 'model' => $selected_model,
8874 'messages' => $formatted_conversation,
8875 'temperature' => 0.8,
8876 'stream' => false
8877 ]);
8878
8879 $args = [
8880 'body' => $body,
8881 'headers' => [
8882 'Content-Type' => 'application/json',
8883 'Authorization' => 'Bearer ' . $deepseek_api_key,
8884 ],
8885 'timeout' => 60,
8886 'redirection' => 5,
8887 'blocking' => true,
8888 'httpversion' => '1.0',
8889 'sslverify' => true,
8890 ];
8891
8892 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
8893
8894 if (is_wp_error($response)) {
8895 $error_message = $response->get_error_message();
8896 //error_log('DeepSeek API Error: ' . $error_message);
8897 return [
8898 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
8899 'error_code' => 'deepseek_connection_error',
8900 'provider' => 'deepseek'
8901 ];
8902 }
8903
8904 $status_code = wp_remote_retrieve_response_code($response);
8905 if ($status_code !== 200) {
8906 $response_body = wp_remote_retrieve_body($response);
8907 $decoded_response = json_decode($response_body, true);
8908
8909 $error_message = isset($decoded_response['error']['message'])
8910 ? $decoded_response['error']['message']
8911 : 'HTTP Error ' . $status_code;
8912
8913 $error_type = isset($decoded_response['error']['type'])
8914 ? $decoded_response['error']['type']
8915 : 'unknown';
8916
8917 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
8918
8919 // Handle specific error types
8920 switch ($status_code) {
8921 case 401:
8922 return [
8923 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
8924 'error_code' => 'deepseek_auth_error',
8925 'provider' => 'deepseek'
8926 ];
8927
8928 case 400:
8929 if (strpos($error_message, 'API key') !== false) {
8930 return [
8931 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
8932 'error_code' => 'deepseek_invalid_api_key',
8933 'provider' => 'deepseek'
8934 ];
8935 }
8936 break;
8937
8938 case 429:
8939 if (strpos($error_message, 'quota') !== false) {
8940 return [
8941 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
8942 'error_code' => 'deepseek_quota_exceeded',
8943 'provider' => 'deepseek'
8944 ];
8945 } else {
8946 return [
8947 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
8948 'error_code' => 'deepseek_rate_limit',
8949 'provider' => 'deepseek'
8950 ];
8951 }
8952
8953 case 500:
8954 case 502:
8955 case 503:
8956 case 504:
8957 return [
8958 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
8959 'error_code' => 'deepseek_service_unavailable',
8960 'provider' => 'deepseek'
8961 ];
8962 }
8963
8964 // Generic error fallback
8965 return [
8966 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
8967 'error_code' => 'deepseek_api_error',
8968 'provider' => 'deepseek',
8969 'status_code' => $status_code
8970 ];
8971 }
8972
8973 $response_body = wp_remote_retrieve_body($response);
8974 $decoded_response = json_decode($response_body, true);
8975
8976 if (isset($decoded_response['choices'][0]['message']['content'])) {
8977 return trim($decoded_response['choices'][0]['message']['content']);
8978 } else {
8979 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
8980 return [
8981 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
8982 'error_code' => 'deepseek_response_format_error',
8983 'provider' => 'deepseek'
8984 ];
8985 }
8986 } catch (Exception $e) {
8987 //error_log('DeepSeek Exception: ' . $e->getMessage());
8988 return [
8989 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
8990 'error_code' => 'deepseek_exception',
8991 'provider' => 'deepseek'
8992 ];
8993 }
8994 }
8995 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
8996 // Get bot ID from session or request
8997 $bot_id = $this->get_current_bot_id($session_id);
8998
8999 // Get system prompt instructions using centralized function
9000 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9001
9002 // Add system prompt to relevant content
9003 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
9004
9005 // Format messages for Gemini API
9006 $formatted_messages = [];
9007
9008 // Add system message as the first user message with role prefix
9009 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
9010 $formatted_messages[] = [
9011 'role' => 'user',
9012 'parts' => [
9013 ['text' => "[System Instructions] " . $content_with_instructions]
9014 ]
9015 ];
9016
9017 // Add model response to acknowledge system instructions
9018 $formatted_messages[] = [
9019 'role' => 'model',
9020 'parts' => [
9021 ['text' => "I understand and will follow these instructions."]
9022 ]
9023 ];
9024
9025 // Process the rest of the conversation history
9026 $current_role = null;
9027 $current_parts = [];
9028
9029 foreach ($conversation_history as $message) {
9030 // Skip the first system message as we already handled it
9031 if ($message['role'] === 'system') {
9032 continue;
9033 }
9034
9035 // Map roles to Gemini format
9036 $gemini_role = '';
9037 if ($message['role'] === 'user') {
9038 $gemini_role = 'user';
9039 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
9040 $gemini_role = 'model';
9041 } else {
9042 // Skip unsupported roles
9043 continue;
9044 }
9045
9046 // If we have a new role, add the previous message
9047 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
9048 $formatted_messages[] = [
9049 'role' => $current_role,
9050 'parts' => $current_parts
9051 ];
9052 $current_parts = [];
9053 }
9054
9055 // Set current role and add text to parts
9056 $current_role = $gemini_role;
9057 $current_parts[] = ['text' => $message['content']];
9058 }
9059
9060 // Add the last message if there's content
9061 if ($current_role !== null && !empty($current_parts)) {
9062 $formatted_messages[] = [
9063 'role' => $current_role,
9064 'parts' => $current_parts
9065 ];
9066 }
9067
9068 // Build the request body
9069 $body = json_encode([
9070 'contents' => $formatted_messages,
9071 'generationConfig' => [
9072 'temperature' => 0.7,
9073 'topP' => 0.95,
9074 'topK' => 40,
9075 'maxOutputTokens' => 8192,
9076 ],
9077 'safetySettings' => [
9078 [
9079 'category' => 'HARM_CATEGORY_HARASSMENT',
9080 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9081 ],
9082 [
9083 'category' => 'HARM_CATEGORY_HATE_SPEECH',
9084 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9085 ],
9086 [
9087 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
9088 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9089 ],
9090 [
9091 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
9092 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9093 ]
9094 ]
9095 ]);
9096
9097 // Prepare the API endpoint
9098 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models
9099 $api_version = (strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
9100 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
9101
9102 // Set up the API request
9103 $args = [
9104 'body' => $body,
9105 'headers' => [
9106 'Content-Type' => 'application/json',
9107 ],
9108 'timeout' => 60,
9109 'redirection' => 5,
9110 'blocking' => true,
9111 'httpversion' => '1.0',
9112 'sslverify' => true,
9113 ];
9114
9115 // Make the API request
9116 $response = wp_remote_post($api_endpoint, $args);
9117
9118 // Process the response
9119 if (is_wp_error($response)) {
9120 return "Sorry, there was an error processing your request: " . $response->get_error_message();
9121 }
9122
9123 $response_body = json_decode(wp_remote_retrieve_body($response), true);
9124
9125 // Handle potential errors in the response
9126 if (isset($response_body['error'])) {
9127 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
9128 return "Sorry, there was an error with the Gemini API: " .
9129 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
9130 }
9131
9132 // Extract the response text
9133 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
9134 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
9135 } else {
9136 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
9137 return "Sorry, I couldn't process that request. The response format was unexpected.";
9138 }
9139 }
9140
9141
9142 public function test_streaming_request() {
9143 $options = get_option('mxchat_options', []);
9144 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
9145
9146 // Detect provider from model prefix
9147 $provider = strtolower(explode('-', $model)[0]);
9148
9149 $sample_prompt = 'Hello! Can you stream this response back to me?';
9150 $messages = [['role' => 'user', 'content' => $sample_prompt]];
9151 $headers = [];
9152 $body = [];
9153 $url = '';
9154 $api_key = '';
9155
9156 switch ($provider) {
9157 case 'gpt':
9158 case 'o1':
9159 $api_key = $options['api_key'] ?? '';
9160 if (empty($api_key)) return '❌ Missing API key for OpenAI';
9161 $url = 'https://api.openai.com/v1/chat/completions';
9162 $headers = [
9163 'Content-Type: application/json',
9164 'Authorization: Bearer ' . $api_key
9165 ];
9166 $body = [
9167 'model' => $model,
9168 'messages' => $messages,
9169 'stream' => true
9170 ];
9171 break;
9172
9173 case 'claude':
9174 $api_key = $options['claude_api_key'] ?? '';
9175 if (empty($api_key)) return '❌ Missing API key for Claude';
9176 $url = 'https://api.anthropic.com/v1/messages';
9177 $headers = [
9178 'Content-Type: application/json',
9179 'x-api-key: ' . $api_key,
9180 'anthropic-version: 2023-06-01'
9181 ];
9182 $body = [
9183 'model' => $model,
9184 'messages' => $messages,
9185 'max_tokens' => 100,
9186 'stream' => true
9187 ];
9188 break;
9189
9190 case 'grok':
9191 $api_key = $options['xai_api_key'] ?? '';
9192 if (empty($api_key)) return '❌ Missing API key for X.AI';
9193 $url = 'https://api.x.ai/v1/chat/completions';
9194 $headers = [
9195 'Content-Type: application/json',
9196 'Authorization: Bearer ' . $api_key
9197 ];
9198 $body = [
9199 'model' => $model,
9200 'messages' => $messages,
9201 'stream' => true
9202 ];
9203 break;
9204
9205 case 'deepseek':
9206 if (empty($deepseek_api_key)) {
9207 $error_response = [
9208 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
9209 'error_code' => 'missing_deepseek_api_key'
9210 ];
9211 if ($testing_data !== null) {
9212 $error_response['testing_data'] = $testing_data;
9213 }
9214 return $error_response;
9215 }
9216 if ($streaming) {
9217 return $this->mxchat_generate_response_deepseek_stream(
9218 $selected_model,
9219 $deepseek_api_key,
9220 $conversation_history,
9221 $relevant_content,
9222 $session_id,
9223 $testing_data // Pass testing data
9224 );
9225 } else {
9226 $response = $this->mxchat_generate_response_deepseek(
9227 $selected_model,
9228 $deepseek_api_key,
9229 $conversation_history,
9230 $relevant_content
9231 );
9232 }
9233 break;
9234
9235 case 'gemini':
9236 $api_key = $options['gemini_api_key'] ?? '';
9237 if (empty($api_key)) return '❌ Missing API key for Gemini';
9238 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
9239 $headers = ['Content-Type: application/json'];
9240 $body = [
9241 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
9242 'generationConfig' => ['temperature' => 0.7]
9243 ];
9244 break;
9245
9246 default:
9247 return '❌ Unsupported provider: ' . $provider;
9248 }
9249
9250 // Do the actual streaming test
9251 $ch = curl_init($url);
9252 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
9253 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
9254 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
9255 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
9256 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9257
9258 $response = curl_exec($ch);
9259 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
9260 $error = curl_error($ch);
9261 curl_close($ch);
9262
9263 if ($error) return "❌ cURL error: $error";
9264 if ($http_code !== 200) {
9265 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
9266 return "❌ HTTP $http_code: $error_message";
9267 }
9268
9269 return true;
9270 }
9271
9272 public function mxchat_dismiss_pre_chat_message() {
9273 // Get and sanitize the user identifier
9274 $user_id = $this->mxchat_get_user_identifier();
9275 $user_id = sanitize_key($user_id);
9276
9277 // Set a transient to track that the user has dismissed the pre-chat message
9278 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9279 set_transient($transient_key, true, DAY_IN_SECONDS);
9280
9281 wp_send_json_success();
9282 }
9283
9284 public function mxchat_check_pre_chat_message_status() {
9285 // Get and sanitize the user identifier
9286 $user_id = $this->mxchat_get_user_identifier();
9287 $user_id = sanitize_key($user_id);
9288
9289 // Check if the transient exists (i.e., if the message was dismissed)
9290 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9291 $dismissed = get_transient($transient_key);
9292
9293 // Log the result to see if it's being set correctly
9294 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
9295
9296 if ($dismissed) {
9297 wp_send_json_success(['dismissed' => true]);
9298 } else {
9299 wp_send_json_success(['dismissed' => false]);
9300 }
9301
9302 wp_die();
9303 }
9304
9305 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
9306 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
9307 return 0;
9308 }
9309
9310 $dotProduct = array_sum(array_map(function ($a, $b) {
9311 return $a * $b;
9312 }, $vectorA, $vectorB));
9313 $normA = sqrt(array_sum(array_map(function ($a) {
9314 return $a * $a;
9315 }, $vectorA)));
9316 $normB = sqrt(array_sum(array_map(function ($b) {
9317 return $b * $b;
9318 }, $vectorB)));
9319
9320 if ($normA == 0 || $normB == 0) {
9321 return 0;
9322 }
9323
9324 return $dotProduct / ($normA * $normB);
9325 }
9326
9327
9328 public function mxchat_enqueue_scripts_styles() {
9329 // Fetch options from the database first to check loading strategy
9330 $this->options = get_option('mxchat_options');
9331 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9332
9333 // Always enqueue CSS immediately
9334 wp_enqueue_style(
9335 'mxchat-chat-css',
9336 plugin_dir_url(__FILE__) . '../css/chat-style.css',
9337 array(),
9338 MXCHAT_VERSION
9339 );
9340
9341 // Protect MxChat CSS from LiteSpeed UCSS/CCSS stripping via data-no-optimize attribute
9342 add_filter('style_loader_tag', function($tag, $handle) {
9343 if ($handle === 'mxchat-chat-css' || strpos($handle, 'mxchat') !== false) {
9344 $tag = str_replace("rel='stylesheet'", "rel='stylesheet' data-no-optimize='1'", $tag);
9345 $tag = str_replace('rel="stylesheet"', 'rel="stylesheet" data-no-optimize="1"', $tag);
9346 }
9347 return $tag;
9348 }, 10, 2);
9349
9350 // Handle script loading based on strategy
9351 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9352 // Enqueue the script normally
9353 wp_enqueue_script(
9354 'mxchat-chat-js',
9355 plugin_dir_url(__FILE__) . '../js/chat-script.js',
9356 array('jquery'),
9357 MXCHAT_VERSION,
9358 true
9359 );
9360
9361 // Add defer attribute if strategy is 'defer'
9362 if ($loading_strategy === 'defer') {
9363 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
9364 }
9365 } else {
9366 // For delay or interaction-based loading, we'll use a custom loader
9367 // Don't enqueue the main script - we'll load it dynamically
9368 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
9369 }
9370
9371 // Protect MxChat JS from LiteSpeed optimization stripping via data-no-optimize attribute
9372 add_filter('script_loader_tag', function($tag, $handle) {
9373 if ($handle === 'mxchat-chat-js' || strpos($handle, 'mxchat') !== false) {
9374 $tag = str_replace('<script ', '<script data-no-optimize="1" ', $tag);
9375 }
9376 return $tag;
9377 }, 10, 2);
9378 $prompts_options = get_option('mxchat_prompts_options', array());
9379
9380 // Check if AI theme is active - if so, skip inline colors in JavaScript
9381 $theme_options = get_option('mxchat_theme_options', array());
9382 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9383 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9384 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9385
9386 // Prepare settings for JavaScript
9387 $style_settings = array(
9388 'ajax_url' => admin_url('admin-ajax.php'),
9389 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9390 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9391 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9392 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9393 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9394 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9395 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9396 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9397 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9398 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9399 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9400 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9401 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9402 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9403 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9404 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9405 'icon_color' => $this->options['icon_color'] ?? '#fff',
9406 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9407 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9408 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9409 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9410 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9411 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9412 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9413 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9414 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9415 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9416 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
9417 'initial_email_state' => null, // Also fixed this undefined variable
9418 'skip_email_check' => true,
9419 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9420 'skip_inline_colors' => $skip_inline_colors,
9421 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9422 );
9423
9424 // For normal/defer loading, use wp_localize_script
9425 // For delayed loading, we store settings in a transient to be output inline
9426 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9427 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
9428 } else {
9429 // Store settings for the delayed loader to use
9430 set_transient('mxchat_delayed_settings_' . get_current_user_id(), $style_settings, 60);
9431 }
9432 }
9433
9434 /**
9435 * Output the delayed script loader for performance optimization
9436 */
9437 public function mxchat_output_delayed_script_loader() {
9438 $this->options = get_option('mxchat_options');
9439 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9440 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
9441
9442 // Get the stored settings
9443 $prompts_options = get_option('mxchat_prompts_options', array());
9444 $theme_options = get_option('mxchat_theme_options', array());
9445 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9446 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9447 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9448
9449 $style_settings = array(
9450 'ajax_url' => admin_url('admin-ajax.php'),
9451 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9452 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9453 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9454 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9455 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9456 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9457 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9458 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9459 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9460 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9461 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9462 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9463 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9464 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9465 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9466 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9467 'icon_color' => $this->options['icon_color'] ?? '#fff',
9468 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9469 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9470 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9471 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9472 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9473 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9474 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9475 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9476 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9477 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9478 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
9479 'initial_email_state' => null,
9480 'skip_email_check' => true,
9481 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9482 'skip_inline_colors' => $skip_inline_colors,
9483 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9484 );
9485
9486 // Determine delay time based on strategy
9487 $delay_ms = 0;
9488 switch ($loading_strategy) {
9489 case 'delay_1s':
9490 $delay_ms = 1000;
9491 break;
9492 case 'delay_3s':
9493 $delay_ms = 3000;
9494 break;
9495 case 'delay_5s':
9496 $delay_ms = 5000;
9497 break;
9498 }
9499
9500 ?>
9501 <script type="text/javascript">
9502 (function() {
9503 var mxchatLoaded = false;
9504 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
9505 window.mxchatChat = mxchatChat;
9506
9507 function loadMxChatScript() {
9508 if (mxchatLoaded) return;
9509 mxchatLoaded = true;
9510
9511 function appendChatScript() {
9512 var script = document.createElement('script');
9513 script.src = <?php echo wp_json_encode($script_url); ?>;
9514 script.type = 'text/javascript';
9515 document.body.appendChild(script);
9516 }
9517
9518 if (typeof jQuery !== 'undefined') {
9519 appendChatScript();
9520 } else {
9521 var jq = document.createElement('script');
9522 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
9523 jq.onload = appendChatScript;
9524 document.body.appendChild(jq);
9525 }
9526 }
9527
9528 <?php if ($loading_strategy === 'on_interaction'): ?>
9529 // Load on user interaction
9530 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
9531 events.forEach(function(evt) {
9532 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
9533 });
9534 // Fallback: load after 8 seconds if no interaction
9535 setTimeout(loadMxChatScript, 8000);
9536 <?php else: ?>
9537 // Load after specified delay
9538 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
9539 <?php endif; ?>
9540 })();
9541 </script>
9542 <?php
9543 }
9544
9545 /**
9546 * Setup the cron jobs for rate limits with guard against multiple calls
9547 */
9548 public function setup_rate_limit_cron_jobs() {
9549 // Add a guard to prevent multiple rapid calls
9550 $last_setup = get_transient('mxchat_cron_setup_guard');
9551 if ($last_setup && (time() - $last_setup) < 60) {
9552 // Don't run again if we ran less than 60 seconds ago
9553 return;
9554 }
9555
9556 // Set the guard
9557 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
9558
9559 try {
9560 // First, check if WordPress cron is disabled
9561 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
9562 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
9563 $this->setup_fallback_rate_limit_system();
9564 return;
9565 }
9566
9567 // Check if cron is already scheduled - if so, don't mess with it
9568 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
9569 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
9570 return;
9571 }
9572
9573 // Clear any orphaned hooks (but don't loop indefinitely)
9574 $hooks_to_clear = [
9575 'mxchat_reset_rate_limits',
9576 'mxchat_reset_hourly_rate_limits',
9577 'mxchat_reset_daily_rate_limits',
9578 'mxchat_reset_weekly_rate_limits',
9579 'mxchat_reset_monthly_rate_limits'
9580 ];
9581
9582 foreach ($hooks_to_clear as $hook) {
9583 // Only clear a maximum of 3 instances to prevent infinite loops
9584 $cleared = 0;
9585 while (wp_next_scheduled($hook) && $cleared < 3) {
9586 wp_clear_scheduled_hook($hook);
9587 $cleared++;
9588 }
9589 }
9590
9591 // Small delay after clearing
9592 usleep(100000); // 0.1 seconds
9593
9594 // Try to schedule the event
9595 $initial_time = time() + 300; // Start in 5 minutes
9596 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
9597
9598 if ($result === false) {
9599 //error_log('MxChat: Failed to schedule cron, using fallback system');
9600 $this->setup_fallback_rate_limit_system();
9601 } else {
9602 //error_log('MxChat: Successfully scheduled rate limit reset cron');
9603 }
9604
9605 } catch (Exception $e) {
9606 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
9607 $this->setup_fallback_rate_limit_system();
9608 }
9609 }
9610
9611 /**
9612 * Try alternative cron scheduling methods
9613 */
9614 private function try_alternative_cron_scheduling($initial_time) {
9615 try {
9616 // Method 1: Try with current time instead of future time
9617 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
9618 if ($result1 !== false) {
9619 //error_log('MxChat: Alternative method 1 (current time) succeeded');
9620 return true;
9621 }
9622
9623 // Method 2: Try with a different interval
9624 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
9625 if ($result2 !== false) {
9626 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
9627 return true;
9628 }
9629
9630 // Method 3: Try wp_schedule_single_event first, then recurring
9631 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
9632 if ($result3 !== false) {
9633 //error_log('MxChat: Alternative method 3 (single event) succeeded');
9634 // Schedule the next one manually in the handler
9635 return true;
9636 }
9637
9638 return false;
9639
9640 } catch (Exception $e) {
9641 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
9642 return false;
9643 }
9644 }
9645
9646 /**
9647 * Enhanced fallback rate limit system
9648 */
9649 private function setup_fallback_rate_limit_system() {
9650 // Set a flag to use database-based rate limit cleanup
9651 update_option('mxchat_use_fallback_rate_limits', true);
9652
9653 // Schedule a one-time check to happen on the next plugin load
9654 update_option('mxchat_next_rate_limit_check', time() + 3600);
9655
9656 // Also set up a more frequent fallback check (every 4 hours)
9657 update_option('mxchat_fallback_check_interval', 4 * 3600);
9658
9659 //error_log('MxChat: Fallback rate limit system activated');
9660 }
9661
9662 /**
9663 * Enhanced fallback check method
9664 */
9665 public function check_fallback_rate_limits() {
9666 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9667
9668 if (!$use_fallback) {
9669 return; // Regular cron is working
9670 }
9671
9672 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9673 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
9674
9675 if (time() >= $next_check) {
9676 //error_log('MxChat: Running fallback rate limit cleanup');
9677 $this->mxchat_reset_rate_limits();
9678
9679 // Schedule next check
9680 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
9681 }
9682 }
9683 /**
9684 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
9685 */
9686 public function check_rate_limit() {
9687 // Check if we need to run fallback cleanup
9688 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9689 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9690
9691 if ($use_fallback && time() >= $next_check) {
9692 $this->mxchat_reset_rate_limits();
9693 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
9694 }
9695
9696 // Get bot ID from current request context
9697 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9698
9699 // Get bot-specific options (includes rate limits if overridden)
9700 $bot_options = $this->get_bot_options($bot_id);
9701 $current_options = !empty($bot_options) ? $bot_options : $this->options;
9702
9703 // Use bot-specific rate limits if available, otherwise fall back to default
9704 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
9705
9706 // Determine user role or if logged out
9707 if (is_user_logged_in()) {
9708 $user = wp_get_current_user();
9709 $user_id = $user->ID;
9710
9711 // Get the user's primary role using reset() to safely get the first element
9712 $user_roles = $user->roles;
9713
9714 // Safely get the first role regardless of array key structure
9715 if (!empty($user_roles) && is_array($user_roles)) {
9716 $role = reset($user_roles); // This safely gets the first element regardless of key
9717 } else {
9718 $role = 'subscriber'; // Default to subscriber if no role found
9719 }
9720 } else {
9721 $role = 'logged_out';
9722 // Use IP address for non-logged-in users
9723 $user_id = $this->get_client_ip();
9724 }
9725
9726 // Check if rate limits are configured for this role
9727 if (!isset($rate_limits_source[$role])) {
9728 return true; // No limit set for this role
9729 }
9730
9731 $limit = $rate_limits_source[$role]['limit'];
9732
9733 // If unlimited, return true immediately
9734 if ($limit === 'unlimited') {
9735 return true;
9736 }
9737
9738 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
9739 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
9740 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
9741 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
9742
9743 // Include bot_id in option name so each bot has separate rate limits
9744 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
9745
9746 // Get the counter data
9747 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
9748
9749 // If first request or counter reset needed, set the initial timestamp
9750 if ($limit_data['count'] === 0) {
9751 $limit_data['timestamp'] = time();
9752 update_option($option_name, $limit_data);
9753 }
9754
9755 // Get the timeframe
9756 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
9757 $rate_limits_source[$role]['timeframe'] : 'daily';
9758
9759 // Check if the counter needs to be reset based on timeframe
9760 $current_time = time();
9761 $timestamp = $limit_data['timestamp'];
9762 $should_reset = false;
9763
9764 switch ($timeframe) {
9765 case 'hourly':
9766 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
9767 break;
9768 case 'daily':
9769 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
9770 break;
9771 case 'weekly':
9772 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
9773 break;
9774 case 'monthly':
9775 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
9776 break;
9777 }
9778
9779 // Reset the counter if the timeframe has passed
9780 if ($should_reset) {
9781 $limit_data = ['count' => 0, 'timestamp' => $current_time];
9782 update_option($option_name, $limit_data);
9783 }
9784
9785 // Check if user has exceeded their limit
9786 if ($limit_data['count'] >= intval($limit)) {
9787 // Get the custom message for this role
9788 $message = !empty($rate_limits_source[$role]['message'])
9789 ? $rate_limits_source[$role]['message']
9790 : __('Rate limit exceeded. Please try again later.', 'mxchat');
9791
9792 // Add timeframe information to the message if placeholders exist
9793 $timeframe_label = '';
9794 switch ($timeframe) {
9795 case 'hourly':
9796 $timeframe_label = __('hour', 'mxchat');
9797 break;
9798 case 'daily':
9799 $timeframe_label = __('day', 'mxchat');
9800 break;
9801 case 'weekly':
9802 $timeframe_label = __('week', 'mxchat');
9803 break;
9804 case 'monthly':
9805 $timeframe_label = __('month', 'mxchat');
9806 break;
9807 }
9808
9809 // Replace placeholders in the message
9810 $message = str_replace(
9811 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
9812 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
9813 $message
9814 );
9815
9816 // Process HTML links in the message
9817 $message = $this->process_rate_limit_message_html($message);
9818
9819 // Return error with the processed message
9820 return [
9821 'error' => true,
9822 'message' => $message
9823 ];
9824 }
9825
9826 // Increment the counter
9827 $limit_data['count']++;
9828 update_option($option_name, $limit_data);
9829
9830 return true;
9831 }
9832
9833 /**
9834 * Enhanced rate limit reset with better error handling
9835 */
9836 public function mxchat_reset_rate_limits() {
9837 try {
9838 global $wpdb;
9839 $all_options = get_option('mxchat_options', []);
9840 $current_time = time();
9841
9842 // Get rate limit options with a safer query and limit
9843 $option_names = $wpdb->get_col(
9844 $wpdb->prepare(
9845 "SELECT option_name FROM {$wpdb->options}
9846 WHERE option_name LIKE %s
9847 LIMIT 1000",
9848 'mxchat_chat_limit_%'
9849 )
9850 );
9851
9852 if (empty($option_names)) {
9853 return;
9854 }
9855
9856 $processed_count = 0;
9857 $max_processing_time = 30; // Maximum 30 seconds
9858 $start_time = time();
9859
9860 foreach ($option_names as $option_name) {
9861 // Check processing time limit
9862 if ((time() - $start_time) > $max_processing_time) {
9863 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
9864 break;
9865 }
9866
9867 // Parse the option name more safely
9868 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
9869 continue;
9870 }
9871
9872 $role_and_user = $matches[1] . '_' . $matches[2];
9873 $parts = explode('_', $role_and_user);
9874
9875 if (count($parts) < 2) {
9876 continue;
9877 }
9878
9879 // Extract role (everything except the last part which is user ID)
9880 $user_id_part = array_pop($parts);
9881 $role = implode('_', $parts);
9882
9883 // Skip if role doesn't exist in our settings
9884 if (!isset($all_options['rate_limits'][$role])) {
9885 // Clean up orphaned entries
9886 delete_option($option_name);
9887 continue;
9888 }
9889
9890 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
9891 $limit_data = get_option($option_name);
9892
9893 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
9894 // Clean up invalid entries
9895 delete_option($option_name);
9896 continue;
9897 }
9898
9899 $timestamp = $limit_data['timestamp'];
9900 $should_reset = false;
9901
9902 // Determine if we should reset based on the timeframe
9903 switch ($timeframe) {
9904 case 'hourly':
9905 $should_reset = ($current_time - $timestamp) >= 3600;
9906 break;
9907 case 'daily':
9908 $should_reset = ($current_time - $timestamp) >= 86400;
9909 break;
9910 case 'weekly':
9911 $should_reset = ($current_time - $timestamp) >= 604800;
9912 break;
9913 case 'monthly':
9914 $should_reset = ($current_time - $timestamp) >= 2592000;
9915 break;
9916 }
9917
9918 // Reset the counter if the timeframe has passed
9919 if ($should_reset) {
9920 delete_option($option_name);
9921 wp_cache_delete($option_name, 'options');
9922 $processed_count++;
9923 }
9924 }
9925
9926 // Clean up any orphaned cache entries
9927 wp_cache_delete('mxchat_all_chat_limits', 'options');
9928
9929 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
9930
9931 } catch (Exception $e) {
9932 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
9933 }
9934 }
9935
9936
9937 /**
9938 * Process HTML links in rate limit messages
9939 *
9940 * @param string $message The rate limit message
9941 * @return string The processed message with safe HTML links
9942 */
9943 private function process_rate_limit_message_html($message) {
9944 // Return original message if empty
9945 if (empty($message)) {
9946 return $message;
9947 }
9948
9949 // First, convert markdown links to HTML
9950 $message = $this->convert_markdown_links($message);
9951
9952 // Then, auto-convert any remaining plain URLs to links
9953 $message = $this->auto_link_urls($message);
9954
9955 // Allow basic HTML tags for links and formatting
9956 $allowed_tags = [
9957 'a' => [
9958 'href' => true,
9959 'target' => true,
9960 'rel' => true,
9961 'title' => true,
9962 'class' => true
9963 ],
9964 'strong' => [],
9965 'em' => [],
9966 'br' => [],
9967 'b' => [],
9968 'i' => [],
9969 'span' => ['class' => true]
9970 ];
9971
9972 // Sanitize but allow the specified HTML tags
9973 $processed_message = wp_kses($message, $allowed_tags);
9974
9975 // If wp_kses stripped everything, return the original message as plain text
9976 if (empty($processed_message) && !empty($message)) {
9977 // Strip all HTML and return plain text as fallback
9978 return wp_strip_all_tags($message);
9979 }
9980
9981 return $processed_message;
9982 }
9983
9984 /**
9985 * Convert markdown links to HTML
9986 *
9987 * @param string $text The text to process
9988 * @return string The text with markdown links converted to HTML
9989 */
9990 private function convert_markdown_links($text) {
9991 // Return original text if empty
9992 if (empty($text)) {
9993 return $text;
9994 }
9995
9996 // Pattern to match markdown links: [text](url)
9997 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
9998
9999 $processed_text = preg_replace_callback($pattern, function($matches) {
10000 $link_text = $matches[1];
10001 $url = $matches[2];
10002
10003 // Clean up any trailing punctuation from the URL
10004 $url = rtrim($url, '.,;:!?');
10005
10006 // Sanitize the link text and URL
10007 $safe_text = esc_html($link_text);
10008 $safe_url = esc_url($url);
10009
10010 // Create the HTML link
10011 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
10012 }, $text);
10013
10014 // If preg_replace_callback failed, return original text
10015 if ($processed_text === null) {
10016 return $text;
10017 }
10018
10019 return $processed_text;
10020 }
10021
10022 /**
10023 * Auto-convert plain URLs to clickable links
10024 *
10025 * @param string $text The text to process
10026 * @return string The text with URLs converted to links
10027 */
10028 private function auto_link_urls($text) {
10029 // Return original text if empty
10030 if (empty($text)) {
10031 return $text;
10032 }
10033
10034 // Simple pattern that avoids complex lookbehinds
10035 // This will match URLs that are not already inside href attributes or markdown links
10036 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
10037
10038 $processed_text = preg_replace_callback($pattern, function($matches) {
10039 $url = $matches[0];
10040 // Clean up any trailing punctuation that might have been captured
10041 $url = rtrim($url, '.,;:!?');
10042
10043 // Add target="_blank" and rel="noopener noreferrer" for security
10044 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
10045 }, $text);
10046
10047 // If preg_replace_callback failed, return original text
10048 if ($processed_text === null) {
10049 return $text;
10050 }
10051
10052 return $processed_text;
10053 }
10054
10055
10056 // Helper function to get client IP address
10057 private function get_client_ip() {
10058 // Check for shared internet/ISP IP
10059 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
10060 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
10061 }
10062
10063 // Check for IPs passing through proxies
10064 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
10065 // Use the first value in the comma-separated list
10066 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
10067 return trim($forwarded_for[0]);
10068 }
10069
10070 if (!empty($_SERVER['REMOTE_ADDR'])) {
10071 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
10072 }
10073
10074 // Fallback
10075 return 'unknown';
10076 }
10077
10078 /**
10079 * AJAX handler to get system information for testing panel
10080 */
10081 /**
10082 * AJAX handler to get system information for testing panel
10083 */
10084 public function mxchat_get_system_info() {
10085 // Verify nonce for security
10086 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10087 wp_send_json_error(['message' => 'Invalid nonce']);
10088 return;
10089 }
10090
10091 // Only allow admin users
10092 if (!current_user_can('administrator')) {
10093 wp_send_json_error(['message' => 'Unauthorized']);
10094 return;
10095 }
10096
10097 // Get system prompt from options
10098 $system_prompt = isset($this->options['system_prompt_instructions'])
10099 ? $this->options['system_prompt_instructions']
10100 : 'No system prompt configured';
10101
10102 // Get selected model
10103 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
10104
10105 // Check if OpenRouter is being used
10106 $is_openrouter = ($selected_model === 'openrouter');
10107 $openrouter_model = '';
10108
10109 if ($is_openrouter) {
10110 // Get the actual OpenRouter model that's selected
10111 $openrouter_model = isset($this->options['openrouter_selected_model'])
10112 ? $this->options['openrouter_selected_model']
10113 : 'No OpenRouter model selected';
10114
10115 // Update selected_model display to show both
10116 $selected_model = 'OpenRouter: ' . $openrouter_model;
10117 }
10118
10119 // Get API key status (just check if they exist, don't expose the keys)
10120 $api_status = [];
10121 $api_status['openai'] = !empty($this->options['api_key']);
10122 $api_status['claude'] = !empty($this->options['claude_api_key']);
10123 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
10124 $api_status['xai'] = !empty($this->options['xai_api_key']);
10125 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
10126 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
10127
10128 wp_send_json_success([
10129 'system_prompt' => $system_prompt,
10130 'selected_model' => $selected_model,
10131 'is_openrouter' => $is_openrouter,
10132 'openrouter_model' => $openrouter_model,
10133 'api_status' => $api_status
10134 ]);
10135 }
10136
10137 /**
10138 * AJAX handler to get similarity threshold
10139 */
10140 public function mxchat_get_similarity_threshold() {
10141 // Verify nonce for security
10142 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10143 wp_send_json_error(['message' => 'Invalid nonce']);
10144 return;
10145 }
10146
10147 // Only allow admin users
10148 if (!current_user_can('administrator')) {
10149 wp_send_json_error(['message' => 'Unauthorized']);
10150 return;
10151 }
10152
10153 // Get similarity threshold from main options (default 35%)
10154 $similarity_threshold = isset($this->options['similarity_threshold'])
10155 ? ((int) $this->options['similarity_threshold']) / 100
10156 : 0.35;
10157
10158 wp_send_json_success([
10159 'threshold' => $similarity_threshold,
10160 'threshold_percentage' => ($similarity_threshold * 100) . '%'
10161 ]);
10162 }
10163
10164 /**
10165 * AJAX handler to get knowledge base status
10166 */
10167 public function mxchat_get_kb_status() {
10168 // Verify nonce for security
10169 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10170 wp_send_json_error(['message' => 'Invalid nonce']);
10171 return;
10172 }
10173
10174 // Only allow admin users
10175 if (!current_user_can('administrator')) {
10176 wp_send_json_error(['message' => 'Unauthorized']);
10177 return;
10178 }
10179
10180 // Check OpenAI Vector Store first (takes priority)
10181 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
10182 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
10183
10184 if ($use_vectorstore) {
10185 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
10186 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
10187
10188 $kb_info = [
10189 'type' => 'OpenAI Vector Store',
10190 'status' => 'Active',
10191 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
10192 ];
10193
10194 wp_send_json_success($kb_info);
10195 return;
10196 }
10197
10198 // Check Pinecone vs WordPress
10199 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10200 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10201
10202 $kb_info = [
10203 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
10204 'status' => 'Active'
10205 ];
10206
10207 // Get document count
10208 if ($use_pinecone) {
10209 $kb_info['documents'] = 'Connected to Pinecone';
10210 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
10211 } else {
10212 // Count documents in WordPress database
10213 global $wpdb;
10214 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10215 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
10216 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
10217 }
10218
10219 wp_send_json_success($kb_info);
10220 }
10221
10222 /**
10223 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
10224 */
10225 public function mxchat_start_fresh_session() {
10226 // Verify nonce for security
10227 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10228 wp_send_json_error(['message' => 'Invalid nonce']);
10229 return;
10230 }
10231
10232 // Only allow admin users
10233 if (!current_user_can('administrator')) {
10234 wp_send_json_error(['message' => 'Unauthorized']);
10235 return;
10236 }
10237
10238 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
10239 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
10240
10241 if (empty($old_session_id)) {
10242 wp_send_json_error(['message' => 'Old session ID required']);
10243 return;
10244 }
10245
10246 // If no new session ID provided, generate one
10247 if (empty($new_session_id)) {
10248 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
10249 }
10250
10251 // Clear ALL data associated with the old session
10252 $this->clear_complete_session_data($old_session_id);
10253
10254 // Initialize the new session
10255 $this->initialize_fresh_session($new_session_id);
10256
10257 wp_send_json_success([
10258 'message' => 'Fresh session started successfully',
10259 'new_session_id' => $new_session_id,
10260 'old_session_id' => $old_session_id
10261 ]);
10262 }
10263
10264 /**
10265 * Clear ALL data associated with a session (ENHANCED)
10266 */
10267 private function clear_complete_session_data($session_id) {
10268 // Clear chat history
10269 delete_option("mxchat_history_{$session_id}");
10270
10271 // Clear chat mode
10272 delete_option("mxchat_mode_{$session_id}");
10273
10274 // Clear any PDF/Word transients
10275 $this->clear_pdf_transients($session_id);
10276 if (method_exists($this, 'clear_word_transients')) {
10277 $this->clear_word_transients($session_id);
10278 }
10279
10280 // Clear agent-related data
10281 delete_option("mxchat_channel_{$session_id}");
10282 delete_option("mxchat_agent_name_{$session_id}");
10283 delete_option("mxchat_email_{$session_id}");
10284
10285 // Clear any recommendation flow state
10286 delete_option("mxchat_sr_flow_state_{$session_id}");
10287
10288 // Clear any cached embeddings or context
10289 delete_transient("mxchat_context_{$session_id}");
10290 delete_transient("mxchat_last_query_{$session_id}");
10291
10292 // Clear any testing data
10293 delete_transient("mxchat_testing_data_{$session_id}");
10294
10295 // Clear any rate limiting data for this session
10296 delete_transient("mxchat_rate_limit_{$session_id}");
10297
10298 // Clear any other session-specific transients
10299 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
10300 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
10301 delete_transient("mxchat_include_word_in_context_{$session_id}");
10302
10303 // Clear form addon state (pending forms and submitted forms)
10304 delete_option("mxchat_pending_form_{$session_id}");
10305 delete_option("mxchat_submitted_forms_{$session_id}");
10306
10307 //error_log("MxChat: Cleared all data for session: {$session_id}");
10308 }
10309
10310 /**
10311 * Initialize a fresh session with default data
10312 */
10313 private function initialize_fresh_session($session_id) {
10314 // Set default chat mode
10315 update_option("mxchat_mode_{$session_id}", 'ai');
10316
10317 //error_log("MxChat: Initialized fresh session: {$session_id}");
10318 }
10319
10320 /**
10321 * Helper method to clear Word document transients (if you have Word support)
10322 */
10323 private function clear_word_transients($session_id) {
10324 delete_transient('mxchat_word_url_' . $session_id);
10325 delete_transient('mxchat_word_filename_' . $session_id);
10326 delete_transient('mxchat_word_embeddings_' . $session_id);
10327 delete_transient('mxchat_include_word_in_context_' . $session_id);
10328 }
10329
10330 /**
10331 * Simplified testing data capture method (CLEANED UP)
10332 */
10333 private function capture_testing_data($user_embedding, $message, $session_id) {
10334 // Only capture for admin users
10335 if (!current_user_can('administrator')) {
10336 return null;
10337 }
10338
10339 $testing_data = [
10340 'query' => $message,
10341 'timestamp' => time(),
10342 'top_matches' => [],
10343 'action_matches' => [] // Add action matches
10344 ];
10345
10346 // Get similarity threshold
10347 $similarity_threshold = isset($this->options['similarity_threshold'])
10348 ? ((int) $this->options['similarity_threshold']) / 100
10349 : 0.35;
10350
10351 $testing_data['similarity_threshold'] = $similarity_threshold;
10352
10353 // Use the real similarity analysis if available
10354 if ($this->last_similarity_analysis !== null) {
10355 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
10356 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
10357 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10358 } else {
10359 // Fallback: determine knowledge base type
10360 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10361 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10362
10363 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
10364 }
10365
10366 // Include action analysis if available
10367 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
10368 $testing_data['action_matches'] = $this->last_action_analysis;
10369
10370 // Clear it after capturing to avoid stale data
10371 $this->last_action_analysis = null;
10372 }
10373
10374 return $testing_data;
10375 }
10376
10377
10378 /**
10379 * Track URL clicks from chatbot responses
10380 */
10381 public function mxchat_track_url_click() {
10382 // Verify nonce for security
10383 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10384 wp_send_json_error(['message' => 'Invalid nonce']);
10385 wp_die();
10386 }
10387
10388 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10389 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
10390 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
10391
10392 if (empty($session_id) || empty($clicked_url)) {
10393 wp_send_json_error(['message' => 'Missing required data']);
10394 wp_die();
10395 }
10396
10397 global $wpdb;
10398 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10399
10400 // Insert click tracking record
10401 $wpdb->insert(
10402 $table_name,
10403 [
10404 'session_id' => $session_id,
10405 'clicked_url' => $clicked_url,
10406 'message_context' => $message_context,
10407 'click_timestamp' => current_time('mysql', 1),
10408 'user_ip' => $_SERVER['REMOTE_ADDR'],
10409 'user_agent' => $_SERVER['HTTP_USER_AGENT']
10410 ]
10411 );
10412
10413 wp_send_json_success(['message' => 'Click tracked']);
10414 wp_die();
10415 }
10416
10417 /**
10418 * Get URL click analytics for a session
10419 */
10420 public function mxchat_get_url_clicks($session_id) {
10421 global $wpdb;
10422 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10423
10424 $clicks = $wpdb->get_results($wpdb->prepare(
10425 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
10426 $session_id
10427 ));
10428
10429 return $clicks;
10430 }
10431 /**
10432 * Track the originating page where chat was started
10433 */
10434 public function mxchat_track_originating_page() {
10435 // Verify nonce
10436 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10437 wp_send_json_error(['message' => 'Invalid nonce']);
10438 wp_die();
10439 }
10440
10441 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10442 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
10443 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
10444
10445 if (empty($session_id)) {
10446 wp_send_json_error(['message' => 'Missing session ID']);
10447 wp_die();
10448 }
10449
10450 global $wpdb;
10451 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
10452
10453 // Check if we've already tracked for this session
10454 $existing = $wpdb->get_var($wpdb->prepare(
10455 "SELECT COUNT(*) FROM $table_name
10456 WHERE session_id = %s
10457 AND originating_page_url IS NOT NULL",
10458 $session_id
10459 ));
10460
10461 if ($existing > 0) {
10462 wp_send_json_success(['message' => 'Already tracked']);
10463 wp_die();
10464 }
10465
10466 // Update the first message in this session with originating page info
10467 $wpdb->query($wpdb->prepare(
10468 "UPDATE $table_name
10469 SET originating_page_url = %s,
10470 originating_page_title = %s
10471 WHERE session_id = %s
10472 ORDER BY timestamp ASC
10473 LIMIT 1",
10474 $page_url,
10475 $page_title,
10476 $session_id
10477 ));
10478
10479 wp_send_json_success(['message' => 'Originating page tracked']);
10480 wp_die();
10481 }
10482
10483 /**
10484 * Validate and clean URLs from AI response
10485 * Removes any URLs that aren't in the knowledge base
10486 *
10487 * @param string $response_text The AI-generated response
10488 * @param array $valid_urls Array of URLs from the knowledge base
10489 * @return string Cleaned response with invalid URLs removed/flagged
10490 */
10491 private function validate_and_clean_urls($response_text, $valid_urls) {
10492 // DEBUG: Log what we're working with
10493 //error_log("=== MxChat URL Validation Debug ===");
10494 //error_log("Valid URLs count: " . count($valid_urls));
10495 //error_log("Valid URLs: " . print_r($valid_urls, true));
10496 //error_log("Response text length: " . strlen($response_text));
10497 //error_log("Response text preview: " . substr($response_text, 0, 500));
10498
10499 // If no valid URLs provided or empty response, return as-is
10500 if (empty($valid_urls) || empty($response_text)) {
10501 //error_log("Validation skipped - empty valid_urls or response");
10502 return $response_text;
10503 }
10504
10505 // Extract all URLs from the AI response
10506 // This regex matches http:// and https:// URLs
10507 preg_match_all(
10508 '#\bhttps?://[^\s<>"\')\]]+#i',
10509 $response_text,
10510 $matches
10511 );
10512
10513 // If no URLs found in response, return as-is
10514 if (empty($matches[0])) {
10515 //error_log("No URLs found in response");
10516 return $response_text;
10517 }
10518
10519 $found_urls = $matches[0];
10520 $cleaned_response = $response_text;
10521 $removed_count = 0;
10522
10523 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
10524 $normalized_valid_urls = array_map(function($url) {
10525 // Remove trailing slash
10526 $url = rtrim($url, '/');
10527 // Remove URL fragments (#section)
10528 $url = preg_replace('/#.*$/', '', $url);
10529 // Remove trailing punctuation that might have been captured
10530 $url = rtrim($url, '.,;:!?');
10531 return $url;
10532 }, $valid_urls);
10533
10534 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
10535
10536 foreach ($found_urls as $found_url) {
10537 // Clean up the found URL (remove trailing punctuation that might have been captured)
10538 $clean_found_url = rtrim($found_url, '.,;:!?)');
10539
10540 // DEBUG: Log each URL being checked
10541 //error_log("Checking found URL: " . $found_url);
10542
10543 // Normalize for comparison
10544 $normalized_found = rtrim($clean_found_url, '/');
10545 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
10546
10547 //error_log("Normalized found URL: " . $normalized_found);
10548
10549 // Check if this URL exists in our valid URLs list
10550 $is_valid = false;
10551
10552 //error_log("Starting validation checks for: " . $normalized_found);
10553
10554 // First, try exact match
10555 if (in_array($normalized_found, $normalized_valid_urls)) {
10556 $is_valid = true;
10557 //error_log("EXACT MATCH FOUND");
10558 } else {
10559 //error_log("No exact match, checking variations...");
10560 // If no exact match, check if it's a variation (with query params, etc.)
10561 foreach ($normalized_valid_urls as $valid_url) {
10562 //error_log(" Comparing against valid URL: " . $valid_url);
10563
10564 // Check if the found URL starts with a valid URL (handles query params)
10565 if (strpos($normalized_found, $valid_url) === 0) {
10566 // Check what comes after the valid URL
10567 $remainder = substr($normalized_found, strlen($valid_url));
10568
10569 // Only valid if:
10570 // 1. Exact match (remainder is empty)
10571 // 2. Query params (starts with ?)
10572 // 3. Fragment (starts with #)
10573 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
10574 $is_valid = true;
10575 //error_log(" MATCH: Found URL is valid variation of base URL");
10576 break;
10577 } else {
10578 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
10579 }
10580 }
10581 // Also check the reverse (in case valid URL has query params)
10582 if (strpos($valid_url, $normalized_found) === 0) {
10583 $is_valid = true;
10584 //error_log(" MATCH: Valid URL starts with found URL");
10585 break;
10586 }
10587 }
10588
10589 if (!$is_valid) {
10590 //error_log("NO MATCH FOUND - URL should be removed");
10591 }
10592 }
10593
10594 // If URL is not valid, remove it from the response
10595 if (!$is_valid) {
10596 // Log the removal for debugging
10597 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
10598 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
10599
10600 $removed_count++;
10601
10602 // Check if URL is part of a markdown link: [text](url)
10603 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
10604 if (preg_match($markdown_pattern, $cleaned_response)) {
10605 //error_log("Found markdown link, removing but keeping text");
10606 // Remove the markdown link but keep the text
10607 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
10608 }
10609 // Check if URL is part of an HTML link: <a href="url">text</a>
10610 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
10611 //error_log("Found HTML link, removing but keeping text");
10612 // Remove the HTML link but keep the text
10613 $link_text = $link_match[1];
10614 $cleaned_response = preg_replace(
10615 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
10616 $link_text,
10617 $cleaned_response
10618 );
10619 }
10620 // Otherwise just remove the bare URL
10621 else {
10622 //error_log("Removing bare URL");
10623 $cleaned_response = str_replace($found_url, '', $cleaned_response);
10624 }
10625 }
10626 }
10627
10628 // Log summary if any URLs were removed
10629 if ($removed_count > 0) {
10630 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
10631 } else {
10632 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
10633 }
10634
10635 // Clean up any double spaces or awkward punctuation left behind
10636 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
10637 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
10638 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
10639
10640 //error_log("Final cleaned response: " . $cleaned_response);
10641
10642 return trim($cleaned_response);
10643 }
10644
10645 /**
10646 * AJAX handler to get current chat mode for a session
10647 */
10648 public function mxchat_get_current_chat_mode() {
10649 // Verify nonce for security
10650 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10651 wp_send_json_error(['message' => 'Invalid nonce']);
10652 wp_die();
10653 }
10654
10655 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10656
10657 if (empty($session_id)) {
10658 wp_send_json_error(['message' => 'Session ID missing']);
10659 wp_die();
10660 }
10661
10662 // Get the current chat mode for this session
10663 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
10664
10665 wp_send_json_success([
10666 'chat_mode' => $chat_mode
10667 ]);
10668 wp_die();
10669 }
10670
10671
10672
10673 }
10674 ?>
10675