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

10,670 lines 425.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 class MxChat_Integrator {
7 private $options;
8 private $prompts_options;
9 private $chat_count;
10 private $fallbackResponse;
11 private $productCardHtml;
12 private $word_handler;
13 private $last_similarity_analysis = null;
14 private $current_valid_urls = [];
15 private $last_vectorstore_error = null;
16 private $is_streaming = false; // ADDED: Track if current request is streaming
17 private $streaming_headers_sent = false; // Track if streaming headers have been sent
18
19 /**
20 * Setup streaming headers - call this right before actually streaming
21 * This delays header setup to allow actions/forms to return JSON responses
22 */
23 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 wp_send_json_success(array('nonce' => wp_create_nonce('mxchat_chat_nonce')));
132 }
133
134 // In your core plugin's check_actions_for_addons method:
135 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
136 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
137
138 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
139
140 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
141
142 return $result;
143 }
144
145 private function mxchat_increment_chat_count() {
146 $chat_count = get_option('mxchat_chat_count', 0);
147 $chat_count++;
148 update_option('mxchat_chat_count', $chat_count);
149 }
150
151 function mxchat_fetch_conversation_history() {
152 if (empty($_POST['session_id'])) {
153 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
154 wp_die();
155 }
156
157 $session_id = sanitize_text_field($_POST['session_id']);
158
159 // SECURITY FIX: Verify session ownership before retrieving data
160 // If IP/user changed, signal frontend to reset session instead of blocking
161 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
162
163 // Check if this session has an owner recorded
164 $session_owner = get_option("mxchat_session_owner_{$session_id}");
165
166 // If session has an owner and it doesn't match current user, trigger session reset
167 if ($session_owner && $session_owner !== $current_user_identifier) {
168 wp_send_json_error([
169 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
170 'code' => 'session_expired',
171 'action' => 'reset_session'
172 ]);
173 wp_die();
174 }
175
176 // If no owner is set yet, claim ownership (for legacy sessions)
177 if (!$session_owner) {
178 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
179 }
180
181 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
182 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
183
184 if (empty($history)) {
185 // Even if history is empty, return the chat mode
186 wp_send_json_success([
187 'conversation' => [],
188 'chat_mode' => $chat_mode
189 ]);
190 wp_die();
191 }
192
193 wp_send_json_success([
194 'conversation' => $history,
195 'chat_mode' => $chat_mode
196 ]);
197 wp_die();
198 }
199 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
200 $history = get_option("mxchat_history_{$session_id}", []);
201
202 // Check persistence setting - when OFF, only include messages from current page load
203 $options = get_option('mxchat_options', []);
204 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
205
206 // Filter history when persistence is OFF to match what the user sees
207 if (!$persistence_enabled && $session_start_timestamp > 0) {
208 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
209 // Include messages from this page load onwards
210 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
211 });
212 // Re-index array after filtering
213 $history = array_values($history);
214 }
215
216 $formatted_history = [];
217
218 // Adjusted for code-heavy conversations
219 $max_tokens = 120000; // Context window size
220 $reserved_tokens = 5000; // Space for system prompts + current query
221 $current_token_count = 0;
222
223 // Allowed HTML tags for content sanitization
224 $allowed_tags = [
225 'pre' => ['class' => true],
226 'code' => ['class' => true],
227 'span' => ['class' => true],
228 'div' => ['class' => true],
229 'strong' => [],
230 'em' => []
231 ];
232
233 foreach (array_reverse($history) as $entry) {
234 // Preserve code blocks while sanitizing other HTML
235 $clean_content = wp_kses($entry['content'], $allowed_tags);
236
237 // Detect code blocks in content
238 $has_code = false;
239 // Replace the HTML check with:
240 // Allow messages that contain code blocks or are plain text
241 if (strpos($clean_content, '<pre') === false &&
242 strpos($clean_content, '<code') === false &&
243 $clean_content !== strip_tags($entry['content'])) {
244 continue;
245 }
246
247 // Skip entries that lost significant content during sanitization
248 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
249 continue;
250 }
251
252 // More accurate token estimation (1 token ≈ 4 characters)
253 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
254
255 // Check token budget with the new estimate
256 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
257 // Try to fit partial content if it's the first entry
258 if (empty($formatted_history)) {
259 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
260 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
261 } else {
262 break;
263 }
264 }
265
266 // Add to formatted history
267 $formatted_history[] = [
268 'role' => $entry['role'],
269 'content' => $clean_content
270 ];
271
272 $current_token_count += $token_estimate;
273 }
274
275 // Reverse back to maintain chronological order
276 $formatted_history = array_reverse($formatted_history);
277
278 // Add system message about code context
279 array_unshift($formatted_history, [
280 'role' => 'system',
281 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
282 . 'Maintain formatting and syntax highlighting when referencing code.'
283 ]);
284
285 return $formatted_history;
286 }
287
288 public function register_routes() {
289 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
290
291 register_rest_route('mxchat/v1', '/stream', [
292 'methods' => 'GET',
293 'callback' => [$this, 'mxchat_stream_events'],
294 'permission_callback' => [$this, 'verify_chat_session'],
295 ]);
296
297 register_rest_route('mxchat/v1', '/agent-response', [
298 'methods' => 'POST',
299 'callback' => [$this, 'mxchat_handle_agent_response'],
300 'permission_callback' => [$this, 'verify_slack_request'],
301 ]);
302
303 register_rest_route('mxchat/v1', '/slack-interaction', [
304 'methods' => 'POST',
305 'callback' => [$this, 'handle_slack_interaction'],
306 'permission_callback' => [$this, 'verify_slack_request'],
307 ]);
308
309 register_rest_route('mxchat/v1', '/slack-messages', [
310 'methods' => 'POST',
311 'callback' => [$this, 'handle_slack_messages'],
312 'permission_callback' => [$this, 'verify_slack_request'],
313 ]);
314
315 // Telegram webhook endpoint
316 register_rest_route('mxchat/v1', '/telegram-webhook', [
317 'methods' => 'POST',
318 'callback' => [$this, 'handle_telegram_webhook'],
319 'permission_callback' => [$this, 'verify_telegram_request'],
320 ]);
321
322 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
323 }
324
325 /**
326 * Verify valid chat session
327 */
328 public function verify_chat_session($request) {
329 $session_id = $request->get_param('session_id');
330 if (empty($session_id)) {
331 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
332 return false;
333 }
334
335 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
336 return $chat_mode === 'agent';
337 }
338
339 /**
340 * Verify request is coming from Slack.
341 *
342 * @param WP_REST_Request $request
343 * @return bool True if valid, false otherwise.
344 */
345 public function verify_slack_request($request) {
346 // Get the Slack signing secret from your plugin options
347 $valid_key = $this->options['live_agent_secret_key'] ?? '';
348
349 if (empty($valid_key)) {
350 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
351 return false;
352 }
353
354 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
355 $slack_signature = $request->get_header('X-Slack-Signature');
356
357 // Verify timestamp to prevent replay attacks
358 if (abs(time() - intval($timestamp)) > 300) {
359 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
360 return false;
361 }
362
363 // Get raw request body from the WP_REST_Request object
364 // (php://input may already be consumed by WordPress at this point)
365 $request_body = $request->get_body();
366
367 // Create the signature base string
368 $sig_basestring = "v0:{$timestamp}:{$request_body}";
369
370 // Calculate expected signature
371 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
372
373 // Compare signatures
374 return hash_equals($my_signature, $slack_signature);
375 }
376
377 /**
378 * Verify request is coming from Telegram.
379 *
380 * @param WP_REST_Request $request
381 * @return bool True if valid, false otherwise.
382 */
383 public function verify_telegram_request($request) {
384 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
385
386 //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
387 //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
388
389 if (empty($secret_token)) {
390 // If no secret is configured, allow the request (for initial setup)
391 //error_log('[MxChat Telegram DEBUG] No secret configured, allowing request');
392 return true;
393 }
394
395 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
396 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
397
398 //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
399
400 if (empty($request_token)) {
401 //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
402 return false;
403 }
404
405 // Timing-safe comparison
406 $result = hash_equals($secret_token, $request_token);
407 //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
408 return $result;
409 }
410
411 public function mxchat_stream_events(WP_REST_Request $request) {
412 header('Content-Type: text/event-stream');
413 header('Cache-Control: no-cache');
414 header('Connection: keep-alive');
415
416 $session_id = sanitize_text_field($request->get_param('session_id'));
417 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
418
419 if (empty($session_id)) {
420 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
421 flush();
422 exit;
423 }
424
425 $history = get_option("mxchat_history_{$session_id}", []);
426
427 // Filter only new messages
428 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
429 return !empty($message['id']) && $message['id'] > $last_seen_id;
430 });
431
432 // Send new messages if available
433 if (!empty($new_messages)) {
434 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
435 } else {
436 // Keep the connection alive
437 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
438 }
439 flush();
440 exit;
441 }
442
443
444
445
446 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
447 global $wpdb;
448 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
449 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
450
451 // Check if this is the first message in a new session (before any other database operations)
452 $is_new_session = false;
453 if ($role === 'user') { // Only check for user messages, not bot responses
454 $existing_messages = $wpdb->get_var($wpdb->prepare(
455 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
456 $session_id
457 ));
458 $is_new_session = ($existing_messages == 0);
459
460 // Log for debugging
461 if ($is_new_session) {
462 //error_log("[DEBUG] This is a NEW session - first message");
463 }
464 }
465
466 // SECURITY FIX: Set session ownership for new sessions
467 if ($is_new_session && $role === 'user') {
468 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
469 $session_owner_key = "mxchat_session_owner_{$session_id}";
470
471 // Only set ownership if not already set
472 if (!get_option($session_owner_key)) {
473 update_option($session_owner_key, $current_user_identifier, 'no');
474 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
475 }
476 }
477
478 // 1) Extract agent name if present
479 $agent_name = '';
480 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
481 $agent_name = $matches[1];
482 $message = str_replace("Agent: $agent_name - ", '', $message);
483 $session_meta_key = "mxchat_agent_name_{$session_id}";
484 if (empty(get_option($session_meta_key))) {
485 update_option($session_meta_key, $agent_name);
486 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
487 }
488 }
489
490 // 2) Generate unique message_id
491 $message_id = uniqid();
492 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
493
494 // 3) Determine user_id
495 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
496
497 // 4) Determine user_identifier
498 $user_identifier = $agent_name
499 ? $agent_name
500 : MxChat_User::mxchat_get_user_identifier();
501
502 // 5) Determine displayed_name
503 $user_email = MxChat_User::mxchat_get_user_email();
504 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
505
506 // 6) Check for a saved email in wp_options
507 $email_option_key = "mxchat_email_{$session_id}";
508 $saved_email = get_option($email_option_key);
509 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
510
511 // Check for a saved name in wp_options
512 $name_option_key = "mxchat_name_{$session_id}";
513 $saved_name = get_option($name_option_key);
514 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
515
516 // If found, update DB user_email and user_name
517 if ($saved_email || $saved_name) {
518 $update_data = [];
519 if ($saved_email) {
520 $update_data['user_email'] = $saved_email;
521 }
522 if ($saved_name) {
523 $update_data['user_name'] = $saved_name;
524 }
525
526 if (!empty($update_data)) {
527 $update_res = $wpdb->update(
528 $table_name,
529 $update_data,
530 ['session_id' => $session_id],
531 array_fill(0, count($update_data), '%s'),
532 ['%s']
533 );
534 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
535 }
536 }
537
538 // 7) Save to session history in wp_options
539 $history_key = "mxchat_history_{$session_id}";
540 $history = get_option($history_key, []);
541 $history[] = [
542 'id' => $message_id,
543 'role' => $role,
544 'content' => $message,
545 'timestamp' => round(microtime(true) * 1000),
546 'agent_name' => $displayed_name,
547 ];
548 update_option($history_key, $history, 'no');
549 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
550
551 // 8) Save the message to DB (INSERT)
552 $insert_data = [
553 'user_id' => $user_id,
554 'user_identifier'=> $user_identifier,
555 'user_email' => $saved_email ?: $user_email,
556 'user_name' => $saved_name ?: '', // Add name to insert data
557 'session_id' => $session_id,
558 'role' => $role,
559 'message' => $message,
560 'timestamp' => current_time('mysql', 1),
561 ];
562
563 // IMPROVED: Handle originating page data
564 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
565
566 if ($columns_exist) {
567 if ($is_new_session && $role === 'user') {
568 // For the first user message, set originating page data
569
570 // First check if we have it from the parameter
571 if ($originating_page && !empty($originating_page['url'])) {
572 $insert_data['originating_page_url'] = $originating_page['url'];
573 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
574
575 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
576 }
577 // Otherwise check if it's stored in the instance property
578 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
579 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
580 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
581
582 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
583
584 // Clear after using
585 unset($this->pending_originating_page);
586 }
587 // Fallback to HTTP_REFERER if nothing else is available
588 else if (isset($_SERVER['HTTP_REFERER'])) {
589 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
590 $insert_data['originating_page_url'] = $referer_url;
591
592 // Generate title from URL
593 $parsed_url = parse_url($referer_url);
594 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
595
596 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
597 $insert_data['originating_page_title'] = 'Homepage';
598 } else {
599 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
600 $insert_data['originating_page_title'] = ucwords(trim($title));
601 }
602
603 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
604 }
605
606 // Store for this session so all messages have the same originating page
607 if (!empty($insert_data['originating_page_url'])) {
608 update_option("mxchat_originating_page_{$session_id}", [
609 'url' => $insert_data['originating_page_url'],
610 'title' => $insert_data['originating_page_title']
611 ], 'no');
612 }
613 } else {
614 // For subsequent messages in the session, use the stored originating page
615 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
616 if ($stored_originating && !empty($stored_originating['url'])) {
617 $insert_data['originating_page_url'] = $stored_originating['url'];
618 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
619 }
620 }
621 }
622
623 // Add RAG context if provided (for bot messages)
624 if ($rag_context !== null && $role === 'bot') {
625 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
626 if ($rag_context_column_exists) {
627 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
628 }
629 }
630
631 $wpdb->insert($table_name, $insert_data);
632 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
633
634 // 9) Send notification email if this is the first user message in a new session
635 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
636 $this->send_new_chat_notification($session_id, array(
637 'identifier' => $user_identifier,
638 'email' => $saved_email ?: $user_email,
639 'ip' => $_SERVER['REMOTE_ADDR']
640 ));
641 }
642
643 // 10) Schedule delayed transcript email if enabled and message is from user
644 if ($wpdb->insert_id && $role === 'user') {
645 $this->schedule_delayed_transcript_email($session_id);
646 }
647
648 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
649 return $message_id;
650 }
651
652 private function send_new_chat_notification($session_id, $user_info = array()) {
653 $options = get_option('mxchat_transcripts_options');
654
655 // Check if notifications are enabled
656 if (empty($options['mxchat_enable_notifications'])) {
657 return false;
658 }
659
660 // Get notification email
661 $to = !empty($options['mxchat_notification_email']) ?
662 $options['mxchat_notification_email'] :
663 get_option('admin_email');
664
665 if (!is_email($to)) {
666 return false;
667 }
668
669 // Prepare email content
670 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
671
672 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
673 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
674 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
675
676 $message = sprintf(
677 "A new chat session has started on your website.\n\n" .
678 "Session ID: %s\n" .
679 "User: %s\n" .
680 "Email: %s\n" .
681 "IP Address: %s\n" .
682 "Time: %s\n\n" .
683 "View transcripts: %s",
684 $session_id,
685 $user_identifier,
686 $user_email,
687 $user_ip,
688 current_time('mysql'),
689 admin_url('admin.php?page=mxchat-transcripts')
690 );
691
692 // Send email
693 return wp_mail($to, $subject, $message);
694 }
695
696 /**
697 * Schedule delayed transcript email for a session
698 * Reschedules if a new user message is received
699 */
700 private function schedule_delayed_transcript_email($session_id) {
701 $options = get_option('mxchat_transcripts_options');
702
703 // Check if auto-email is enabled
704 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
705 return;
706 }
707
708 // Get notification email
709 $email = !empty($options['mxchat_notification_email']) ?
710 $options['mxchat_notification_email'] :
711 get_option('admin_email');
712
713 if (!is_email($email)) {
714 return;
715 }
716
717 // Get delay in minutes (default 30)
718 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
719 intval($options['mxchat_auto_email_transcript_delay']) : 30;
720
721 // Clear any existing scheduled event for this session
722 $hook = 'mxchat_send_delayed_transcript';
723 $args = array($session_id);
724 $timestamp = wp_next_scheduled($hook, $args);
725
726 if ($timestamp) {
727 wp_unschedule_event($timestamp, $hook, $args);
728 }
729
730 // Schedule new event
731 $schedule_time = time() + ($delay_minutes * 60);
732 wp_schedule_single_event($schedule_time, $hook, $args);
733 }
734
735 /**
736 * Check if chat messages contain contact information (email or phone number)
737 *
738 * @param array $messages Array of message objects with 'message' property
739 * @param object|null $session_data Session data object with user_email property
740 * @return bool True if contact info found, false otherwise
741 */
742 private function chat_contains_contact_info($messages, $session_data = null) {
743 // Check if session already has a stored email
744 if ($session_data && !empty($session_data->user_email)) {
745 return true;
746 }
747
748 // Email regex pattern
749 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
750
751 // Phone number patterns (covers various formats including international, WhatsApp style)
752 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
753 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
754
755 // Only check user messages (not assistant responses)
756 foreach ($messages as $msg) {
757 if ($msg->role !== 'user') {
758 continue;
759 }
760
761 $message_text = $msg->message;
762
763 // Check for email
764 if (preg_match($email_pattern, $message_text)) {
765 return true;
766 }
767
768 // Check for phone number (must be at least 7 digits total to avoid false positives)
769 if (preg_match($phone_pattern, $message_text, $matches)) {
770 // Count actual digits to avoid matching short numbers
771 $digits_only = preg_replace('/\D/', '', $matches[0]);
772 if (strlen($digits_only) >= 7) {
773 return true;
774 }
775 }
776 }
777
778 return false;
779 }
780
781 /**
782 * Send the delayed transcript email with .txt attachment
783 */
784 public function mxchat_send_delayed_transcript($session_id) {
785 global $wpdb;
786
787 $options = get_option('mxchat_transcripts_options');
788
789 // Get notification email
790 $to = !empty($options['mxchat_notification_email']) ?
791 $options['mxchat_notification_email'] :
792 get_option('admin_email');
793
794 if (!is_email($to)) {
795 return false;
796 }
797
798 // Get all messages for this session
799 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
800 $messages = $wpdb->get_results($wpdb->prepare(
801 "SELECT role, message, timestamp FROM {$table_name}
802 WHERE session_id = %s
803 ORDER BY timestamp ASC",
804 $session_id
805 ));
806
807 if (empty($messages)) {
808 return false;
809 }
810
811 // Get session metadata
812 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
813 $session_data = $wpdb->get_row($wpdb->prepare(
814 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
815 $session_id
816 ));
817
818 // Check if contact info is required and if it's present
819 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
820 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
821 // Contact info required but not found - skip sending
822 return false;
823 }
824
825 // Build transcript content
826 $transcript_content = "Chat Transcript\n";
827 $transcript_content .= "================\n\n";
828 $transcript_content .= "Session ID: " . $session_id . "\n";
829
830 if ($session_data) {
831 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
832 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
833 $transcript_content .= "Started: " . $session_data->created_at . "\n";
834 }
835
836 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
837
838 // Add messages
839 foreach ($messages as $msg) {
840 $role_label = ($msg->role === 'user') ? 'User' : 'Assistant';
841 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
842 $transcript_content .= $msg->message . "\n\n";
843 }
844
845 // Create temporary file for attachment using WP_Filesystem
846 $upload_dir = wp_upload_dir();
847 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
848 global $wp_filesystem;
849 if (empty($wp_filesystem)) {
850 require_once ABSPATH . 'wp-admin/includes/file.php';
851 WP_Filesystem();
852 }
853 $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
854
855 // Prepare email
856 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
857
858 $message = "Please find attached the full chat transcript.\n\n";
859 $message .= "Session ID: {$session_id}\n";
860
861 if ($session_data) {
862 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
863 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
864 }
865
866 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
867
868 // Send email with attachment
869 $attachments = array($temp_file);
870 $result = wp_mail($to, $subject, $message, '', $attachments);
871
872 // Clean up temporary file
873 if (file_exists($temp_file)) {
874 unlink($temp_file);
875 }
876
877 return $result;
878 }
879
880
881
882 public function mxchat_handle_save_email_and_response() {
883 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
884 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
885
886 // Validate nonce
887 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
888 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
889 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
890 wp_die();
891 }
892
893 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
894 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
895 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
896
897 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
898
899 if (empty($session_id) || empty($email)) {
900 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
901 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
902 wp_die();
903 }
904
905 // Validate name if provided (check if name field is enabled and name is required)
906 $options = get_option('mxchat_options', []);
907 $name_field_enabled = isset($options['enable_name_field']) &&
908 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
909
910 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
911 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
912 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
913 wp_die();
914 }
915
916 // 1) Always store email in wp_options
917 $email_option_key = "mxchat_email_{$session_id}";
918 update_option($email_option_key, $email);
919 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
920
921 // Store name in wp_options if provided
922 if (!empty($name)) {
923 $name_option_key = "mxchat_name_{$session_id}";
924 update_option($name_option_key, $name);
925 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
926 }
927
928 // 2) (Optional) Also store in DB if a row already exists
929 global $wpdb;
930 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
931
932 // Make sure we have a valid placeholder in prepare
933 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
934 $session_count = $wpdb->get_var($sql);
935
936 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
937
938 if ($session_count) {
939 // Update both user_email and user_name if row(s) exist
940 if (!empty($name)) {
941 $update_sql = $wpdb->prepare(
942 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
943 $email,
944 $name,
945 $session_id
946 );
947 } else {
948 $update_sql = $wpdb->prepare(
949 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
950 $email,
951 $session_id
952 );
953 }
954 $wpdb->query($update_sql);
955 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
956 } else {
957 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
958 }
959
960 // Provide success response (same as original)
961 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
962 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
963 wp_send_json_success(['message' => $bot_message]);
964 wp_die();
965 }
966
967 public function mxchat_check_email_provided() {
968 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
969
970 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
971 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
972 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
973 }
974
975 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
976 if (empty($session_id)) {
977 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
978 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
979 }
980
981 // Check if the user is logged in
982 if (is_user_logged_in()) {
983 $current_user = wp_get_current_user();
984 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
985
986 // Get user's display name for logged in users
987 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
988 (!empty($current_user->first_name) ? $current_user->first_name : '');
989
990 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
991 if (!empty($user_name)) {
992 $response_data['name'] = $user_name;
993 }
994
995 wp_send_json_success($response_data);
996 }
997
998 // Check if name field is required
999 $options = get_option('mxchat_options', []);
1000 $name_field_enabled = isset($options['enable_name_field']) &&
1001 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1002
1003 $email_option_key = "mxchat_email_{$session_id}";
1004 $stored_email = get_option($email_option_key, '');
1005
1006 // Check for stored name
1007 $name_option_key = "mxchat_name_{$session_id}";
1008 $stored_name = get_option($name_option_key, '');
1009
1010 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1011 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1012
1013 // Check if we have email and name (if name is required)
1014 $has_required_info = !empty($stored_email);
1015
1016 if ($name_field_enabled) {
1017 $has_required_info = $has_required_info && !empty($stored_name);
1018 }
1019
1020 if ($has_required_info) {
1021 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1022
1023 $response_data = ['email' => $stored_email];
1024 if (!empty($stored_name)) {
1025 $response_data['name'] = $stored_name;
1026 }
1027
1028 wp_send_json_success($response_data);
1029 } else {
1030 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1031 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1032 }
1033 }
1034
1035 /**
1036 * Send error response in appropriate format based on streaming mode
1037 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1038 *
1039 * @param string $error_message The error message to display
1040 * @param string $error_code Optional error code for debugging
1041 */
1042 private function send_error_response($error_message, $error_code = 'api_error') {
1043 if ($this->is_streaming) {
1044 echo "data: " . json_encode([
1045 'error' => true,
1046 'error_message' => $error_message,
1047 'error_code' => $error_code,
1048 'text' => $error_message,
1049 'message' => $error_message
1050 ]) . "\n\n";
1051 echo "data: [DONE]\n\n";
1052 flush();
1053 } else {
1054 wp_send_json_error([
1055 'error_message' => $error_message,
1056 'error_code' => $error_code
1057 ]);
1058 }
1059 wp_die();
1060 }
1061
1062 public function mxchat_handle_chat_request() {
1063 global $wpdb;
1064
1065 // Debug: Log incoming bot_id
1066 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1067 //error_log("=== MXCHAT DEBUG: Starting chat request ===");
1068 //error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1069
1070 // Get bot-specific options
1071 $bot_options = $this->get_bot_options($bot_id);
1072 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1073
1074 // Check if this is a streaming request
1075 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1076 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1077 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1078 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1079
1080 // ADDED: Store streaming state in class property for use in private methods
1081 $this->is_streaming = $is_streaming;
1082
1083 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1084 // This allows actions/forms to return JSON responses without header conflicts
1085
1086 // Check if MX Chat Moderation is active
1087 if (class_exists('MX_Chat_Moderation')) {
1088 // Get user email and IP
1089 $user_email = '';
1090 $user_ip = $_SERVER['REMOTE_ADDR'];
1091
1092 // If user is logged in, get their email
1093 if (is_user_logged_in()) {
1094 $current_user = wp_get_current_user();
1095 $user_email = $current_user->user_email;
1096 }
1097
1098 // Create ban handler instance
1099 $ban_handler = new MX_Chat_Ban_Handler();
1100
1101 // Check if user is banned by IP
1102 if ($ban_handler->check_ban($user_ip, 'ip')) {
1103 wp_send_json([
1104 'success' => false,
1105 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1106 'status' => 'banned'
1107 ]);
1108 wp_die();
1109 }
1110
1111 // If user is logged in, also check email
1112 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1113 wp_send_json([
1114 'success' => false,
1115 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1116 'status' => 'banned'
1117 ]);
1118 wp_die();
1119 }
1120 }
1121
1122 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1123 $this->productCardHtml = '';
1124
1125 // Get the actual WordPress user ID if logged in
1126 $is_logged_in = is_user_logged_in();
1127 if ($is_logged_in) {
1128 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1129 } else {
1130 // For logged-out users, use your existing identifier method
1131 $user_id = $this->mxchat_get_user_identifier();
1132 }
1133
1134 // Get and sanitize the user identifier
1135 $user_id = sanitize_key($user_id);
1136
1137 // Check rate limit using new settings structure
1138 $rate_limit_result = $this->check_rate_limit();
1139
1140 if ($rate_limit_result !== true) {
1141 wp_send_json([
1142 'success' => false,
1143 'message' => $rate_limit_result['message'],
1144 'status' => 'rate_limit_exceeded'
1145 ]);
1146 wp_die();
1147 }
1148
1149 // Rest of your existing code...
1150 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1151
1152 if (empty($session_id)) {
1153 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1154 wp_die();
1155 }
1156
1157 // SECURITY FIX: Verify session ownership before processing chat request
1158 // If IP/user changed, signal frontend to reset session instead of blocking
1159 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1160 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1161
1162 if ($session_owner && $session_owner !== $current_user_identifier) {
1163 // Instead of blocking, tell frontend to start a fresh session
1164 wp_send_json_error([
1165 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
1166 'code' => 'session_expired',
1167 'action' => 'reset_session'
1168 ]);
1169 wp_die();
1170 }
1171
1172 // Validate and sanitize the incoming message
1173 if (empty($_POST['message'])) {
1174 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1175 wp_die();
1176 }
1177
1178
1179 // Track originating page for first message in session
1180 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1181
1182 // Check if originating page columns exist
1183 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1184
1185 if ($columns_exist) {
1186 // Check if this session already has messages
1187 $message_count = $wpdb->get_var($wpdb->prepare(
1188 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1189 $session_id
1190 ));
1191
1192 // If this is the first message in the session
1193 if ($message_count == 0) {
1194 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1195 $originating_url = '';
1196 $originating_title = '';
1197
1198 // Try to get from POST data first (sent by JavaScript)
1199 if (isset($_POST['current_page_url'])) {
1200 $originating_url = esc_url_raw($_POST['current_page_url']);
1201 $originating_title = isset($_POST['current_page_title'])
1202 ? sanitize_text_field($_POST['current_page_title'])
1203 : '';
1204 }
1205 // Fallback to HTTP_REFERER if not provided by JavaScript
1206 else if (isset($_SERVER['HTTP_REFERER'])) {
1207 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1208 }
1209
1210 // Generate title if we have URL but no title
1211 if ($originating_url && empty($originating_title)) {
1212 $parsed_url = parse_url($originating_url);
1213 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1214
1215 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1216 $originating_title = 'Homepage';
1217 } else {
1218 // Clean up the path to make a readable title
1219 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1220 $originating_title = ucwords(trim($originating_title));
1221 }
1222 }
1223
1224 // Store for later use when saving the message
1225 $this->pending_originating_page = [
1226 'url' => $originating_url,
1227 'title' => $originating_title
1228 ];
1229 }
1230 }
1231
1232
1233
1234 // Get page context if provided
1235 $page_context = null;
1236 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1237 $page_context_raw = stripslashes($_POST['page_context']);
1238 $page_context = json_decode($page_context_raw, true);
1239
1240 // Validate page context structure
1241 if (is_array($page_context) &&
1242 isset($page_context['url']) &&
1243 isset($page_context['title']) &&
1244 isset($page_context['content'])) {
1245
1246 // Sanitize page context
1247 $page_context['url'] = esc_url_raw($page_context['url']);
1248 $page_context['title'] = sanitize_text_field($page_context['title']);
1249 $page_context['content'] = wp_kses_post($page_context['content']);
1250 } else {
1251 $page_context = null;
1252 }
1253 }
1254
1255 // Modify the message sanitization to preserve PHP tags in code blocks
1256 $allowed_tags = [
1257 'pre' => [],
1258 'code' => ['class' => true],
1259 'span' => ['class' => true],
1260 'div' => ['class' => true],
1261 ];
1262
1263 // First preserve code blocks
1264 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1265 return htmlspecialchars_decode($matches[0]);
1266 }, $_POST['message']);
1267
1268 // Then apply sanitization
1269 $message = wp_kses($message, $allowed_tags);
1270
1271 // Preserve code blocks from markdown conversion
1272 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1273 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1274
1275 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1276 // Always initialize testing data for admins (no toggle needed)
1277 $testing_data = null;
1278 if (current_user_can('administrator')) {
1279 // For vision messages, use the original user message for the query display
1280 $query_for_testing = $message;
1281 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1282 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1283 }
1284
1285 $testing_data = [
1286 'query' => $query_for_testing,
1287 'timestamp' => time(),
1288 'top_matches' => [],
1289 'action_matches' => [], // Initialize action matches array
1290 'page_context' => $page_context, // Include page context in testing data
1291 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1292 'bot_id' => $bot_id // Include bot ID in testing data
1293 ];
1294
1295 // Get similarity threshold from bot options or default options
1296 $similarity_threshold = isset($current_options['similarity_threshold'])
1297 ? ((int) $current_options['similarity_threshold']) / 100
1298 : 0.35;
1299
1300 $testing_data['similarity_threshold'] = $similarity_threshold;
1301
1302 // Determine knowledge base type using bot-specific config
1303 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1304 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1305 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1306 }
1307 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1308
1309 // Add debug before and after:
1310 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1311 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1312 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1313
1314
1315 // If the pre-processing returned a result (not the original message), use it directly
1316 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1317 // Save the AI response
1318 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1319
1320 // Save HTML content if provided
1321 if (!empty($pre_processed_result['html'])) {
1322 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1323 }
1324
1325 // Add testing data if admin
1326 $response_data = [
1327 'text' => $pre_processed_result['text'],
1328 'html' => $pre_processed_result['html'] ?? '',
1329 'session_id' => $session_id
1330 ];
1331
1332 if ($testing_data !== null) {
1333 $response_data['testing_data'] = $testing_data;
1334 }
1335
1336 wp_send_json($response_data);
1337 wp_die();
1338 }
1339
1340 // Save the user's message - handle vision processed messages differently
1341 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1342 // For vision messages, save the original user message with image indicator
1343 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1344 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1345 $image_count = intval($_POST['vision_images_count']);
1346 $original_message .= " [{$image_count} image(s)]";
1347 }
1348 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1349 } else {
1350 // Regular message - save as normal
1351 $this->mxchat_save_chat_message($session_id, 'user', $message);
1352 }
1353
1354
1355 if (is_email($message)) {
1356 // Add the email to Loops
1357 $this->add_email_to_loops($message);
1358
1359 // Get the user's success message instruction using current_options
1360 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1361
1362 // Set instruction for AI using the user's success message
1363 $this->current_action_instruction = $user_success_message;
1364
1365 // Clear the email capture transient since we got the email
1366 delete_transient('mxchat_email_capture_' . $user_id);
1367 }
1368
1369 // Check if we're in an email capture flow but user hasn't provided email yet
1370 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1371 // Check if the message contains an email (not the whole message being an email)
1372 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1373 $extracted_email = $matches[0];
1374
1375 // Add the extracted email to Loops
1376 $this->add_email_to_loops($extracted_email);
1377
1378 // Get the user's success message instruction using current_options
1379 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1380
1381 // Set instruction for AI using the user's success message
1382 $this->current_action_instruction = $user_success_message;
1383
1384 // Clear the email capture transient since we got the email
1385 delete_transient('mxchat_email_capture_' . $user_id);
1386 }
1387 // If no email found but we're in capture mode, remind them
1388 else {
1389 // Get the original instruction to remind them using current_options
1390 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1391 $this->current_action_instruction = $original_instruction;
1392 }
1393 }
1394
1395 $intent_info = '';
1396
1397 // Check chat mode
1398 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1399
1400 // Handle agent mode
1401 // Handle agent mode
1402 if ($chat_mode === 'agent') {
1403 // First, check for switch intent before doing anything else
1404 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1405
1406 // Capture action analysis for testing panel after intent check
1407 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1408 $testing_data['action_matches'] = $this->last_action_analysis;
1409 }
1410
1411 // Around line 506, in the agent mode handling section:
1412 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1413 // Update chat mode first
1414 update_option("mxchat_mode_{$session_id}", 'ai');
1415
1416 // Clear any existing PDF context to start fresh
1417 $this->clear_pdf_transients($session_id);
1418
1419 // Prepare clean switch response with explicit chat_mode
1420 $response_data = [
1421 'text' => $this->fallbackResponse['text'],
1422 'html' => $this->fallbackResponse['html'] ?? '',
1423 'session_id' => $session_id,
1424 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1425 ];
1426
1427 if ($testing_data !== null) {
1428 $response_data['testing_data'] = $testing_data;
1429 }
1430
1431 // Save the mode switch message
1432 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1433 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1434
1435 // Send response and exit
1436 wp_send_json($response_data);
1437 wp_die();
1438 } elseif (!$intent_matched) {
1439 // No intent matched, handle live agent message
1440 try {
1441 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1442
1443 $agent_response = [
1444 'status' => 'waiting_for_agent',
1445 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1446 ];
1447
1448 if ($testing_data !== null) {
1449 $agent_response['testing_data'] = $testing_data;
1450 }
1451
1452 wp_send_json_success($agent_response);
1453 } catch (\Exception $e) {
1454 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1455 }
1456 wp_die();
1457 }
1458 }
1459
1460 // Step 1: Check for new PDF URL in the message
1461 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1462 $new_pdf_url = $matches[0];
1463
1464 // Check if this is likely a PDF-related request
1465 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1466 $is_pdf_request = false;
1467
1468 foreach ($pdf_keywords as $keyword) {
1469 if (stripos($message, $keyword) !== false) {
1470 $is_pdf_request = true;
1471 break;
1472 }
1473 }
1474
1475 // If it looks like a PDF request or we're waiting for a PDF URL
1476 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1477 // Validate HTTPS
1478 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1479 // Extract filename from URL
1480 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1481
1482 // Clear previous PDF transients
1483 $this->clear_pdf_transients($session_id);
1484
1485 // Process new PDF using current_options
1486 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1487 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1488
1489 if ($embeddings === 'too_many_pages') {
1490 $error_text = sprintf(
1491 $current_options['pdf_intent_error_text'] ??
1492 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1493 $max_pages
1494 );
1495 $this->fallbackResponse['text'] = $error_text;
1496 } elseif ($embeddings) {
1497 // Store new PDF information
1498 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1499
1500 // If the filename is generic, create a more descriptive one
1501 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1502 strpos($pdf_filename, '.php') !== false) {
1503 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1504 }
1505
1506 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1507 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1508 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1509 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1510
1511 $success_text = $current_options['pdf_intent_success_text'] ??
1512 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1513
1514 $pdf_response = [
1515 'success' => true,
1516 'message' => $success_text,
1517 'data' => [
1518 'filename' => $pdf_filename
1519 ]
1520 ];
1521
1522 if ($testing_data !== null) {
1523 $pdf_response['testing_data'] = $testing_data;
1524 }
1525
1526 wp_send_json($pdf_response);
1527 wp_die();
1528 } else {
1529 $error_text = $current_options['pdf_intent_error_text'] ??
1530 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1531 $this->fallbackResponse['text'] = $error_text;
1532 }
1533
1534 $pdf_error_response = [
1535 'success' => false,
1536 'message' => $this->fallbackResponse['text']
1537 ];
1538
1539 if ($testing_data !== null) {
1540 $pdf_error_response['testing_data'] = $testing_data;
1541 }
1542
1543 wp_send_json($pdf_error_response);
1544 wp_die();
1545 }
1546 }
1547 }
1548
1549
1550 // Step 2: Detect intent and handle intent-based responses
1551 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1552
1553 // Capture action analysis for testing panel after intent check
1554 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1555 $testing_data['action_matches'] = $this->last_action_analysis;
1556 }
1557
1558 // Step 3: Handle the intent result appropriately
1559 if ($intent_result !== false) {
1560 // Intent was matched - ALWAYS send as JSON response, never streaming
1561
1562 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1563 // Intent returned a direct response array
1564 $response_data = [
1565 'text' => $intent_result['text'] ?? '',
1566 'html' => $intent_result['html'] ?? '',
1567 'session_id' => $session_id
1568 ];
1569
1570 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
1571 if (isset($intent_result['chat_mode'])) {
1572 $response_data['chat_mode'] = $intent_result['chat_mode'];
1573 }
1574
1575 if ($testing_data !== null) {
1576 $response_data['testing_data'] = $testing_data;
1577 }
1578
1579 wp_send_json($response_data);
1580 wp_die();
1581 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1582 // Intent returned true and set fallbackResponse
1583
1584 // SAVE TO TRANSCRIPT
1585 if (!empty($this->fallbackResponse['text'])) {
1586 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1587 }
1588 // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
1589 if (!empty($this->fallbackResponse['html'])) {
1590 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1591 }
1592
1593 $response_data = [
1594 'text' => $this->fallbackResponse['text'] ?? '',
1595 'html' => $this->fallbackResponse['html'] ?? '',
1596 'session_id' => $session_id
1597 ];
1598
1599 if (isset($this->fallbackResponse['chat_mode'])) {
1600 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
1601 }
1602
1603 if ($testing_data !== null) {
1604 $response_data['testing_data'] = $testing_data;
1605 }
1606
1607 wp_send_json($response_data);
1608 wp_die();
1609 }
1610 }
1611
1612 // If we get here, no intent matched OR the intent didn't provide a usable response
1613
1614 // Step 4: Generate AI response
1615 // Get session start timestamp - when persistence is OFF, only include messages from this page load
1616 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
1617 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
1618 $this->mxchat_increment_chat_count();
1619
1620 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
1621 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
1622 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
1623
1624 // Check if the embedding generation returned an error
1625 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
1626 $error_message = $user_message_embedding['error'];
1627 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
1628
1629 // FIXED: Send error in appropriate format based on streaming mode
1630 if ($is_streaming) {
1631 echo "data: " . json_encode([
1632 'error' => true,
1633 'error_message' => $error_message,
1634 'error_code' => $error_code,
1635 'text' => $error_message,
1636 'message' => $error_message
1637 ]) . "\n\n";
1638 echo "data: [DONE]\n\n";
1639 flush();
1640 } else {
1641 wp_send_json_error([
1642 'error_message' => $error_message,
1643 'error_code' => $error_code
1644 ]);
1645 }
1646 wp_die();
1647 }
1648
1649 // Check if the embedding is valid
1650 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
1651 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1652
1653 // FIXED: Send error in appropriate format based on streaming mode
1654 if ($is_streaming) {
1655 echo "data: " . json_encode([
1656 'error' => true,
1657 'error_message' => $error_message,
1658 'error_code' => 'invalid_embedding',
1659 'text' => $error_message,
1660 'message' => $error_message
1661 ]) . "\n\n";
1662 echo "data: [DONE]\n\n";
1663 flush();
1664 } else {
1665 wp_send_json_error([
1666 'error_message' => $error_message,
1667 'error_code' => 'invalid_embedding'
1668 ]);
1669 }
1670 wp_die();
1671 }
1672
1673 // Build context with both knowledge base and PDF content if available
1674 $context_content = "User asked: '{$message}'\n\n";
1675
1676 // Add action instruction if present (add this right after the above line)
1677 if (!empty($this->current_action_instruction)) {
1678 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
1679 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
1680 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
1681 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1682
1683 // Clear the instruction after using it
1684 $this->current_action_instruction = null;
1685 }
1686
1687
1688 // Add page context if available and contextual awareness is enabled using current_options
1689 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
1690 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
1691 $context_content .= "Page URL: " . $page_context['url'] . "\n";
1692 $context_content .= "Page Title: " . $page_context['title'] . "\n";
1693 $context_content .= "Page Content: " . $page_context['content'] . "\n";
1694 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
1695 }
1696
1697 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
1698 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
1699
1700 // NEW: Also extract URLs from system instructions (only if citation links enabled)
1701 // Use fresh options to ensure we get the latest setting value
1702 $fresh_options = get_option('mxchat_options', []);
1703 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
1704
1705 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
1706 if ($citation_links_enabled && !empty($system_instructions)) {
1707 preg_match_all(
1708 '#\bhttps?://[^\s<>"\']+#i',
1709 $system_instructions,
1710 $system_instruction_urls
1711 );
1712
1713 if (!empty($system_instruction_urls[0])) {
1714 // Merge with existing valid URLs
1715 $this->current_valid_urls = array_merge(
1716 $this->current_valid_urls,
1717 $system_instruction_urls[0]
1718 );
1719 // Remove duplicates
1720 $this->current_valid_urls = array_unique($this->current_valid_urls);
1721
1722 //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
1723 }
1724 }
1725
1726 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
1727 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
1728 // Update testing data with the REAL similarity analysis
1729 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
1730 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1731 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
1732 $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
1733 $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
1734 }
1735 // ===== END SIMILARITY DATA CAPTURE =====
1736
1737 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
1738 if ($testing_data !== null && !empty($this->current_valid_urls)) {
1739 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
1740 //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
1741 }
1742
1743 if (!empty($relevant_content)) {
1744 $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
1745 } else {
1746 $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
1747 }
1748
1749 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
1750 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
1751 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
1752 $context_content .= "You may ONLY use these exact URLs in your response:\n";
1753 foreach ($this->current_valid_urls as $url) {
1754 $context_content .= "- " . $url . "\n";
1755 }
1756 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
1757 $context_content .= "===== END APPROVED URLS =====\n\n";
1758 }
1759
1760 // Check for and include PDF content
1761 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
1762 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
1763 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
1764 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
1765 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
1766 if (!empty($relevant_pdf_pages)) {
1767 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
1768 foreach ($relevant_pdf_pages as $page_data) {
1769 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
1770 }
1771 $context_content .= "\n";
1772 }
1773 }
1774
1775 // Check for and include Word content
1776 $word_url = get_transient('mxchat_word_url_' . $session_id);
1777 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
1778 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
1779 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
1780 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
1781 if (!empty($relevant_word_chunks)) {
1782 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
1783 foreach ($relevant_word_chunks as $chunk_data) {
1784 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
1785 }
1786 $context_content .= "\n";
1787 }
1788 }
1789
1790 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
1791
1792 // Extract model from current options for bot-specific model support
1793 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.1-chat-latest';
1794
1795 $response = $this->mxchat_generate_response(
1796 $context_content,
1797 $current_options['api_key'] ?? $this->options['api_key'],
1798 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
1799 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
1800 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
1801 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
1802 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
1803 $conversation_history,
1804 $is_streaming,
1805 $session_id,
1806 $testing_data,
1807 $selected_model
1808 );
1809
1810 // Handle streaming vs non-streaming responses
1811 if ($is_streaming) {
1812 // Check if streaming actually happened or if it fell back to regular response
1813 if ($response === true) {
1814 wp_die();
1815 }
1816 // If we get here, streaming fell back to regular response, continue
1817 // But if there's an error, we need to send it as SSE format since headers are already set
1818 if (is_array($response) && isset($response['error'])) {
1819 $error_message = $response['error'];
1820 $error_code = $response['error_code'] ?? 'api_error';
1821 // Send error in SSE format that the client JS can handle
1822 echo "data: " . json_encode([
1823 'error' => true,
1824 'error_message' => $error_message,
1825 'error_code' => $error_code,
1826 'text' => $error_message, // Also include as text for fallback handling
1827 'message' => $error_message
1828 ]) . "\n\n";
1829 echo "data: [DONE]\n\n";
1830 flush();
1831 wp_die();
1832 }
1833 }
1834
1835 // Check if the response is an error array (non-streaming mode)
1836 if (is_array($response) && isset($response['error'])) {
1837 wp_send_json_error([
1838 'error_message' => $response['error'],
1839 'error_code' => $response['error_code'] ?? 'api_error'
1840 ]);
1841 wp_die();
1842 }
1843
1844 // DEBUG: Check what we have
1845 //error_log("=== BEFORE URL VALIDATION ===");
1846 //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
1847 //error_log("current_valid_urls count: " . count($this->current_valid_urls));
1848 //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
1849
1850 // If we get here, the response is valid text - now validate URLs
1851 if (!empty($this->current_valid_urls)) {
1852 //error_log("CALLING validate_and_clean_urls");
1853 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls);
1854 } else {
1855 //error_log("SKIPPING validation - current_valid_urls is empty");
1856 }
1857 // ===== END URL VALIDATION =====
1858
1859 // Prepare RAG context data for storage (only include documents used for context)
1860 $rag_context_for_storage = null;
1861 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
1862 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
1863
1864 if ($has_rag_data || $has_action_data) {
1865 $rag_context_for_storage = [];
1866
1867 // Add RAG/source data if available
1868 if ($has_rag_data) {
1869 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
1870 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
1871 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
1872 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
1873 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1874 $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
1875 $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
1876 }
1877
1878 // Add action analysis data if available
1879 if ($has_action_data) {
1880 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
1881 }
1882 }
1883
1884 // Save the cleaned response with RAG context
1885 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
1886
1887 // Step 5: Save additional content if available
1888 if (!empty($this->productCardHtml)) {
1889 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
1890 }
1891
1892 if (!empty($this->fallbackResponse['html'])) {
1893 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1894 }
1895
1896 // Step 6: Return the response
1897 // DEBUG: Check if newlines exist in the response
1898 //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
1899 //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
1900 //error_log("Response first 500 chars: " . substr($response, 0, 500));
1901
1902 $response_data = [
1903 'text' => $response,
1904 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
1905 'session_id' => $session_id
1906 ];
1907
1908 // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
1909 if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
1910 $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
1911 }
1912
1913 // Also pass it as a top-level field so JS can show a better error message to admins
1914 if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
1915 $response_data['vectorstore_error'] = $this->last_vectorstore_error;
1916 }
1917
1918 // Always add testing data for admins (no toggle needed)
1919 if ($testing_data !== null) {
1920 $response_data['testing_data'] = $testing_data;
1921 }
1922
1923 wp_send_json($response_data);
1924 wp_die();
1925 }
1926
1927 /**
1928 * Get bot-specific options for multi-bot functionality
1929 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
1930 */
1931 // Also debug the bot options retrieval
1932 private function get_bot_options($bot_id = 'default') {
1933 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
1934
1935 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1936 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
1937 return array();
1938 }
1939
1940 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1941
1942 if (!empty($bot_options)) {
1943 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
1944 if (isset($bot_options['similarity_threshold'])) {
1945 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
1946 }
1947 }
1948
1949 return is_array($bot_options) ? $bot_options : array();
1950 }
1951
1952 /**
1953 * Get bot-specific Pinecone configuration
1954 * Used in the knowledge retrieval functions
1955 */
1956 // Also add debugging to your get_bot_pinecone_config function
1957 private function get_bot_pinecone_config($bot_id = 'default') {
1958 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
1959
1960 // If default bot or multi-bot add-on not active, use default Pinecone config
1961 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1962 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
1963 $addon_options = get_option('mxchat_pinecone_addon_options', array());
1964 $config = array(
1965 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
1966 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
1967 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
1968 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
1969 );
1970 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
1971 return $config;
1972 }
1973
1974 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
1975
1976 // Hook for multi-bot add-on to provide bot-specific Pinecone config
1977 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1978
1979 if (!empty($bot_pinecone_config)) {
1980 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
1981 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
1982 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
1983 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
1984 } else {
1985 //error_log("MXCHAT DEBUG: Filter returned empty config!");
1986 }
1987
1988 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
1989 }
1990
1991
1992 // Updated function to check intents and invoke the callback function
1993 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
1994 global $wpdb;
1995 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1996
1997 // Get the current bot_id
1998 $current_bot_id = $this->get_current_bot_id($session_id);
1999
2000 // Generate the user embedding
2001 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
2002
2003 // Check if embedding generation returned an error
2004 if (is_array($user_embedding) && isset($user_embedding['error'])) {
2005 $error_message = $user_embedding['error'];
2006 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
2007
2008 // FIXED: Send error in appropriate format based on streaming mode
2009 if ($this->is_streaming) {
2010 echo "data: " . json_encode([
2011 'error' => true,
2012 'error_message' => $error_message,
2013 'error_code' => $error_code,
2014 'text' => $error_message,
2015 'message' => $error_message
2016 ]) . "\n\n";
2017 echo "data: [DONE]\n\n";
2018 flush();
2019 } else {
2020 wp_send_json_error([
2021 'error_message' => $error_message,
2022 'error_code' => $error_code
2023 ]);
2024 }
2025 wp_die();
2026 }
2027
2028 // Check if embedding is valid
2029 if (!is_array($user_embedding) || empty($user_embedding)) {
2030 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2031
2032 // FIXED: Send error in appropriate format based on streaming mode
2033 if ($this->is_streaming) {
2034 echo "data: " . json_encode([
2035 'error' => true,
2036 'error_message' => $error_message,
2037 'error_code' => 'invalid_embedding',
2038 'text' => $error_message,
2039 'message' => $error_message
2040 ]) . "\n\n";
2041 echo "data: [DONE]\n\n";
2042 flush();
2043 } else {
2044 wp_send_json_error([
2045 'error_message' => $error_message,
2046 'error_code' => 'invalid_embedding'
2047 ]);
2048 }
2049 wp_die();
2050 }
2051
2052 // Fetch intents from the database
2053 $table_name = $wpdb->prefix . 'mxchat_intents';
2054 if ($chat_mode === 'agent') {
2055 $query = $wpdb->prepare(
2056 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2057 'mxchat_handle_switch_to_chatbot_intent'
2058 );
2059 $intents = $wpdb->get_results($query);
2060 } else {
2061 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2062 }
2063
2064 if (empty($intents)) {
2065 return false;
2066 }
2067
2068 $highest_similarity = -INF;
2069 $matched_intent = null;
2070
2071 // Array to store action analysis for testing panel
2072 $action_analysis = [];
2073
2074 foreach ($intents as $intent) {
2075 // Additional check for enabled state
2076 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2077 if (!$is_enabled) {
2078 continue;
2079 }
2080
2081 // Check if this action is enabled for the current bot
2082 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2083 continue;
2084 }
2085
2086 $intent_embedding_serialized = $intent->embedding_vector;
2087 $intent_embedding = $intent_embedding_serialized
2088 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2089 : null;
2090
2091 if (!is_array($intent_embedding)) {
2092 continue;
2093 }
2094
2095 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2096 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2097
2098 // Store action analysis data for testing panel
2099 $action_analysis[] = [
2100 'intent_label' => $intent->intent_label,
2101 'callback_function' => $intent->callback_function,
2102 'similarity' => round($similarity, 4),
2103 'similarity_percentage' => round($similarity * 100, 2),
2104 'threshold' => $intent_threshold,
2105 'threshold_percentage' => round($intent_threshold * 100, 2),
2106 'above_threshold' => $similarity >= $intent_threshold,
2107 'triggered' => false // Will be updated below if this intent is triggered
2108 ];
2109
2110 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2111 $highest_similarity = $similarity;
2112 $matched_intent = $intent;
2113 }
2114 }
2115
2116 // Mark the triggered action if any
2117 if ($matched_intent) {
2118 foreach ($action_analysis as &$action) {
2119 if ($action['intent_label'] === $matched_intent->intent_label) {
2120 $action['triggered'] = true;
2121 break;
2122 }
2123 }
2124 }
2125
2126 // Sort actions by similarity (highest first) and store for testing panel
2127 usort($action_analysis, function($a, $b) {
2128 return $b['similarity'] <=> $a['similarity'];
2129 });
2130
2131 // Store action analysis for testing panel capture
2132 $this->last_action_analysis = $action_analysis;
2133
2134 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2135 if ($matched_intent) {
2136 // If the callback is a method on this instance (core callback), call it directly
2137 if (method_exists($this, $matched_intent->callback_function)) {
2138 $callback_result = call_user_func(
2139 [$this, $matched_intent->callback_function],
2140 $message,
2141 $user_id,
2142 $session_id,
2143 $matched_intent,
2144 $user_context ?? null
2145 );
2146 } else {
2147 // Otherwise, use apply_filters for add-on callbacks
2148 $callback_result = apply_filters(
2149 $matched_intent->callback_function,
2150 false,
2151 $message,
2152 $user_id,
2153 $session_id,
2154 $matched_intent
2155 );
2156 }
2157
2158 // Handle the callback result properly
2159 if ($callback_result !== false) {
2160 // If callback returned an array with chat_mode, use it directly
2161 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2162 $this->fallbackResponse = $callback_result;
2163 return $callback_result; // Return the full array
2164 } else {
2165 $this->fallbackResponse = $callback_result;
2166 return true;
2167 }
2168 }
2169 }
2170
2171 return false;
2172 }
2173
2174 /**
2175 * Check if an action is enabled for a specific bot
2176 */
2177 private function is_action_enabled_for_bot($intent, $bot_id) {
2178 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2179 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2180 return true;
2181 }
2182
2183 $enabled_bots = json_decode($intent->enabled_bots, true);
2184
2185 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2186 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2187 return true;
2188 }
2189
2190 // Check if the current bot is in the enabled bots list
2191 return in_array($bot_id, $enabled_bots);
2192 }
2193
2194 // Helper function to clear PDF and Word document related transients
2195 private function clear_pdf_transients($session_id) {
2196 // PDF transients
2197 delete_transient('mxchat_pdf_url_' . $session_id);
2198 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2199 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2200 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2201
2202 // Word document transients
2203 delete_transient('mxchat_word_url_' . $session_id);
2204 delete_transient('mxchat_word_filename_' . $session_id);
2205 delete_transient('mxchat_word_embeddings_' . $session_id);
2206 delete_transient('mxchat_include_word_in_context_' . $session_id);
2207 delete_transient('mxchat_waiting_for_word_' . $session_id);
2208 }
2209
2210
2211
2212 //verified good
2213 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2214 // Get the user's original instruction/message
2215 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2216
2217 // Set instruction for AI - just pass along what the user wanted to say
2218 $this->current_action_instruction = $user_instruction;
2219
2220 // Set the transient to track email capture flow
2221 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2222
2223 // Return false to let the AI generate the response
2224 return false;
2225 }
2226
2227 public function mxchat_generate_image($message, $user_id, $session_id) {
2228 //error_log("Starting image generation for message: " . $message);
2229
2230 // Prepare a prompt for OpenAI image generation
2231 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2232
2233 // Use the existing OpenAI API key
2234 $openai_api_key = sanitize_text_field($this->options['api_key']);
2235
2236 // Call OpenAI GPT Image to generate an image
2237 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2238
2239 // Check if the response contains an image URL
2240 if (isset($image_response['imageUrl'])) {
2241 $image_url = esc_url_raw($image_response['imageUrl']);
2242
2243 // Construct the HTML with a CSS class instead of inline styles
2244 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2245 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2246
2247 // Save the bot message with both text and HTML
2248 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2249 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2250
2251 // Set the fallback response for the chat handler
2252 $this->fallbackResponse = [
2253 'text' => $response_text,
2254 'html' => $response_html,
2255 'images' => [$image_url]
2256 ];
2257
2258 // For debugging/verification - Use json_encode to verify what's being set
2259 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2260
2261 // Return the response directly instead of relying on the property
2262 return $this->fallbackResponse;
2263 } else {
2264 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2265
2266 // Save the error message
2267 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2268
2269 // Set the fallback response for the chat handler
2270 $this->fallbackResponse = [
2271 'text' => $response_text,
2272 'html' => '',
2273 'images' => []
2274 ];
2275
2276 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2277 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2278
2279 // Return the response directly instead of relying on the property
2280 return $this->fallbackResponse;
2281 }
2282 }
2283
2284 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2285 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2286
2287 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2288 if (empty($gemini_api_key)) {
2289 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2290 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2291 return ['text' => $response_text, 'html' => '', 'images' => []];
2292 }
2293
2294 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2295
2296 if (isset($image_response['imageUrl'])) {
2297 $image_url = esc_url_raw($image_response['imageUrl']);
2298
2299 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2300 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2301
2302 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2303 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2304
2305 $this->fallbackResponse = [
2306 'text' => $response_text,
2307 'html' => $response_html,
2308 'images' => [$image_url]
2309 ];
2310
2311 return $this->fallbackResponse;
2312 } else {
2313 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2314
2315 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2316
2317 $this->fallbackResponse = [
2318 'text' => $response_text,
2319 'html' => '',
2320 'images' => []
2321 ];
2322
2323 return $this->fallbackResponse;
2324 }
2325 }
2326
2327 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
2328 $extension = ($mime_type === 'image/jpeg') ? 'jpg' : 'png';
2329 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
2330 $decoded = base64_decode($base64_data);
2331
2332 if ($decoded === false) {
2333 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
2334 }
2335
2336 $upload = wp_upload_bits($filename, null, $decoded);
2337
2338 if (!empty($upload['error'])) {
2339 return new \WP_Error('upload_failed', $upload['error']);
2340 }
2341
2342 $attach_id = wp_insert_attachment([
2343 'post_mime_type' => $mime_type,
2344 'post_title' => $prefix,
2345 'post_content' => '',
2346 'post_status' => 'inherit',
2347 ], $upload['file']);
2348
2349 if (is_wp_error($attach_id)) {
2350 return $attach_id;
2351 }
2352
2353 require_once ABSPATH . 'wp-admin/includes/image.php';
2354 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
2355 wp_update_attachment_metadata($attach_id, $metadata);
2356
2357 return esc_url_raw(wp_get_attachment_url($attach_id));
2358 }
2359
2360 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
2361 $api_url = 'https://api.openai.com/v1/images/generations';
2362 $body = json_encode([
2363 'prompt' => sanitize_text_field($prompt),
2364 'n' => 1,
2365 'size' => '1024x1024',
2366 'quality' => 'medium',
2367 'output_format' => 'png',
2368 'model' => sanitize_text_field($model),
2369 ]);
2370
2371 $args = [
2372 'body' => $body,
2373 'headers' => [
2374 'Content-Type' => 'application/json',
2375 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
2376 ],
2377 'method' => 'POST',
2378 'timeout' => absint($timeout),
2379 ];
2380
2381 $response = wp_remote_post($api_url, $args);
2382
2383 if (is_wp_error($response)) {
2384 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2385 }
2386
2387 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2388
2389 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
2390 if ($b64) {
2391 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
2392 if (is_wp_error($saved_url)) {
2393 return ['error' => $saved_url->get_error_message()];
2394 }
2395 return ['imageUrl' => $saved_url];
2396 } else {
2397 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2398 }
2399 }
2400
2401 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
2402 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
2403
2404 $body = json_encode([
2405 'instances' => [['prompt' => sanitize_text_field($prompt)]],
2406 'parameters' => [
2407 'sampleCount' => 1,
2408 'aspectRatio' => '1:1',
2409 ],
2410 ]);
2411
2412 $args = [
2413 'body' => $body,
2414 'headers' => [
2415 'Content-Type' => 'application/json',
2416 'x-goog-api-key' => sanitize_text_field($api_key),
2417 ],
2418 'method' => 'POST',
2419 'timeout' => absint($timeout),
2420 ];
2421
2422 $response = wp_remote_post($api_url, $args);
2423
2424 if (is_wp_error($response)) {
2425 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2426 }
2427
2428 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2429
2430 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
2431 if ($b64) {
2432 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
2433 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
2434 if (is_wp_error($saved_url)) {
2435 return ['error' => $saved_url->get_error_message()];
2436 }
2437 return ['imageUrl' => $saved_url];
2438 } else {
2439 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2440 }
2441 }
2442
2443 /**
2444 * Handle web search requests.
2445 *
2446 * Sends the refined search query to the Brave Search API and uses the
2447 * results to generate a conversational response with the AI model.
2448 *
2449 * @since 1.0.0
2450 * @param string $message The user's search query.
2451 * @param string $user_id The user identifier.
2452 * @param string $session_id The current session ID.
2453 * @return array Response array containing text with embedded HTML links
2454 */
2455 public function mxchat_handle_search_request($message, $user_id, $session_id) {
2456 // Step 1: Interpret and refine the search query
2457 $refined_search_query = $this->mxchat_interpret_search_query($message);
2458 if (empty($refined_search_query)) {
2459 return array(
2460 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
2461 'html' => ''
2462 );
2463 }
2464
2465 // Retrieve and validate API settings
2466 $options = get_option('mxchat_options');
2467 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2468 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
2469
2470 if (empty($api_key)) {
2471 return array(
2472 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
2473 'html' => ''
2474 );
2475 }
2476
2477 // Build the API request URL
2478 $api_url = add_query_arg(
2479 array(
2480 'q' => rawurlencode($refined_search_query),
2481 'count' => $results_count,
2482 'text_decorations' => 'true',
2483 'rich_data' => 'true',
2484 ),
2485 'https://api.search.brave.com/res/v1/web/search'
2486 );
2487
2488 // Attempt to retrieve cached results first
2489 $transient_key = 'mxchat_search_' . md5($refined_search_query);
2490 $results = get_transient($transient_key);
2491
2492 if (false === $results) {
2493 // SECURITY FIX: Changed to wp_safe_remote_get
2494 $response = wp_safe_remote_get(
2495 $api_url,
2496 array(
2497 'headers' => array(
2498 'Accept' => 'application/json',
2499 'Accept-Encoding' => 'gzip',
2500 'X-Subscription-Token'=> $api_key,
2501 ),
2502 'timeout' => 10,
2503 )
2504 );
2505
2506 if (is_wp_error($response)) {
2507 return array(
2508 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
2509 'html' => ''
2510 );
2511 }
2512
2513 $results = json_decode(wp_remote_retrieve_body($response), true);
2514
2515 if (json_last_error() !== JSON_ERROR_NONE) {
2516 return array(
2517 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
2518 'html' => ''
2519 );
2520 }
2521
2522 // Cache results for one hour
2523 set_transient($transient_key, $results, HOUR_IN_SECONDS);
2524 }
2525
2526 // Process results
2527 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
2528 // Create a more straightforward summary with HTML links
2529 $search_results_text = '';
2530
2531 // Add a simple intro
2532 $search_results_text .= sprintf(
2533 esc_html__("Here's what I found about '%s':", 'mxchat'),
2534 esc_html($refined_search_query)
2535 );
2536
2537 // Add the top results with HTML links
2538 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
2539 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
2540 $url = isset($result['url']) ? esc_url($result['url']) : '';
2541 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
2542
2543 // Add a line break after the intro
2544 $search_results_text .= '<br><br>';
2545
2546 // Add title as a link
2547 $search_results_text .= sprintf(
2548 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
2549 $url,
2550 $title
2551 );
2552
2553 // Add a condensed description
2554 $search_results_text .= sprintf("%s", $description);
2555 }
2556
2557 // Save to chat history
2558 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
2559
2560 // Return the formatted text with embedded HTML links
2561 return array(
2562 'text' => $search_results_text,
2563 'html' => ''
2564 );
2565 } else {
2566 return array(
2567 'text' => sprintf(
2568 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
2569 esc_html($refined_search_query)
2570 ),
2571 'html' => ''
2572 );
2573 }
2574 }
2575
2576 //very good
2577 /**
2578 * Handle image search requests from the chatbot
2579 *
2580 * @param string $message The user's search query
2581 * @param int $user_id The user's ID
2582 * @param string $session_id The chat session ID
2583 * @return array Response array with text and HTML content
2584 */
2585 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
2586 // Step 1: Interpret the search query using the user's selected AI model
2587 $refined_search_query = $this->mxchat_interpret_search_query($message);
2588
2589 // If no query was interpreted, return a fallback message
2590 if (empty($refined_search_query)) {
2591 return array(
2592 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
2593 'html' => "",
2594 );
2595 }
2596
2597 // Brave API URL
2598 $api_url = 'https://api.search.brave.com/res/v1/images/search';
2599
2600 // Retrieve Brave API settings
2601 $options = get_option('mxchat_options');
2602 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2603
2604 if (empty($api_key)) {
2605 return array(
2606 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
2607 'html' => "",
2608 );
2609 }
2610
2611 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2612 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
2613
2614 // Append query parameters based on settings
2615 $api_url = add_query_arg([
2616 'q' => rawurlencode($refined_search_query),
2617 'count' => $image_count,
2618 'safesearch' => $safe_search,
2619 ], $api_url);
2620
2621 // Implement caching
2622 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
2623 $body = get_transient($transient_key);
2624
2625 if (false === $body) {
2626 $args = [
2627 'headers' => [
2628 'Accept' => 'application/json',
2629 'Accept-Encoding' => 'gzip',
2630 'X-Subscription-Token' => $api_key,
2631 ],
2632 'timeout' => 10,
2633 ];
2634
2635 // SECURITY FIX: Changed to wp_safe_remote_get
2636 $response = wp_safe_remote_get($api_url, $args);
2637
2638 if (is_wp_error($response)) {
2639 return array(
2640 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
2641 'html' => "",
2642 );
2643 }
2644
2645 $body = json_decode(wp_remote_retrieve_body($response), true);
2646 set_transient($transient_key, $body, HOUR_IN_SECONDS);
2647 }
2648
2649 // Process the API response
2650 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
2651 $html_output = '<div class="mxchat-image-gallery">';
2652
2653 // Get the configured image count (1-6)
2654 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2655 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
2656
2657 // Use only the requested number of images
2658 for ($i = 0; $i < $display_count; $i++) {
2659 $image = $body['results'][$i];
2660 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
2661 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
2662 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
2663
2664 if ($image_url && $thumbnail_url) {
2665 $html_output .= '<div class="mxchat-image-item">';
2666 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
2667 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
2668 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
2669 $html_output .= '</a></div>';
2670 }
2671 }
2672
2673 $html_output .= '</div>';
2674
2675 // Create response text
2676 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
2677
2678 // Save both response text and HTML to chat history
2679 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2680 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
2681
2682 // Return the combined response
2683 return array(
2684 'text' => $response_text,
2685 'html' => $html_output,
2686 );
2687 } else {
2688 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
2689
2690 // Save the error message to chat history
2691 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2692
2693 return array(
2694 'text' => $response_text,
2695 'html' => "",
2696 );
2697 }
2698 }
2699
2700 /**
2701 * Interpret the search query using the user's selected AI model
2702 *
2703 * @param string $user_query The original query from the user
2704 * @return string The refined search query
2705 */
2706 public function mxchat_interpret_search_query($user_query) {
2707 $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');
2708
2709 // Get options and determine the selected model
2710 $options = $this->options ?? get_option('mxchat_options');
2711 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest';
2712
2713 // Extract model prefix to determine the provider
2714 $model_parts = explode('-', $selected_model);
2715 $provider = strtolower($model_parts[0]);
2716
2717 // Determine which API key to use based on the provider
2718 switch ($provider) {
2719 case 'gemini':
2720 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
2721 if (empty($api_key)) {
2722 return sanitize_text_field($user_query); // Default to original query if API key missing
2723 }
2724 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
2725
2726 case 'claude':
2727 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
2728 if (empty($api_key)) {
2729 return sanitize_text_field($user_query);
2730 }
2731 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
2732
2733 case 'grok':
2734 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
2735 if (empty($api_key)) {
2736 return sanitize_text_field($user_query);
2737 }
2738 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
2739
2740 case 'deepseek':
2741 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
2742 if (empty($api_key)) {
2743 return sanitize_text_field($user_query);
2744 }
2745 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
2746
2747 case 'gpt':
2748 default:
2749 // Default to OpenAI for custom models or unrecognized prefixes
2750 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
2751 if (empty($api_key)) {
2752 return sanitize_text_field($user_query);
2753 }
2754 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
2755 }
2756 }
2757
2758 /**
2759 * Interpret query using OpenAI models
2760 */
2761 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.1-chat-latest') {
2762 $url = 'https://api.openai.com/v1/chat/completions';
2763 $args = [
2764 'headers' => [
2765 'Authorization' => 'Bearer ' . $api_key,
2766 'Content-Type' => 'application/json',
2767 ],
2768 'body' => wp_json_encode([
2769 'model' => $model,
2770 'messages' => [
2771 ['role' => 'system', 'content' => $system_prompt],
2772 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2773 ],
2774 'temperature' => 0.2,
2775 'max_tokens' => 20,
2776 ]),
2777 'method' => 'POST',
2778 'timeout' => 15,
2779 ];
2780
2781 $response = wp_remote_post($url, $args);
2782 if (is_wp_error($response)) {
2783 return sanitize_text_field($user_query);
2784 }
2785
2786 $body = json_decode(wp_remote_retrieve_body($response), true);
2787 return isset($body['choices'][0]['message']['content'])
2788 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
2789 : sanitize_text_field($user_query);
2790 }
2791
2792 /**
2793 * Interpret query using Claude models
2794 */
2795 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
2796 $url = 'https://api.anthropic.com/v1/messages';
2797
2798 $args = [
2799 'headers' => [
2800 'Content-Type' => 'application/json',
2801 'x-api-key' => $api_key,
2802 'anthropic-version' => '2023-06-01',
2803 ],
2804 'body' => wp_json_encode([
2805 'model' => $model,
2806 'system' => $system_prompt,
2807 'messages' => [
2808 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
2809 ],
2810 'max_tokens' => 20,
2811 'temperature' => 0.2,
2812 ]),
2813 'method' => 'POST',
2814 'timeout' => 15,
2815 ];
2816
2817 $response = wp_remote_post($url, $args);
2818 if (is_wp_error($response)) {
2819 return sanitize_text_field($user_query);
2820 }
2821
2822 $body = json_decode(wp_remote_retrieve_body($response), true);
2823 if (!empty($body['content'][0]['text'])) {
2824 return sanitize_text_field(trim($body['content'][0]['text']));
2825 }
2826
2827 return sanitize_text_field($user_query);
2828 }
2829
2830 /**
2831 * Interpret query using Gemini models
2832 */
2833 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
2834 // Use v1beta for preview models, v1 for stable models
2835 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
2836
2837 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
2838
2839 $args = [
2840 'headers' => [
2841 'Content-Type' => 'application/json',
2842 ],
2843 'body' => wp_json_encode([
2844 'contents' => [
2845 [
2846 'role' => 'user',
2847 'parts' => [
2848 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
2849 ]
2850 ]
2851 ],
2852 'generationConfig' => [
2853 'temperature' => 0.2,
2854 'maxOutputTokens' => 20,
2855 ],
2856 ]),
2857 'method' => 'POST',
2858 'timeout' => 15,
2859 ];
2860
2861 $response = wp_remote_post($url, $args);
2862 if (is_wp_error($response)) {
2863 return sanitize_text_field($user_query);
2864 }
2865
2866 $body = json_decode(wp_remote_retrieve_body($response), true);
2867 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
2868 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
2869 }
2870
2871 return sanitize_text_field($user_query);
2872 }
2873
2874 /**
2875 * Interpret query using X.AI (Grok) models
2876 */
2877 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
2878 $url = 'https://api.xai.com/v1/chat/completions';
2879
2880 $args = [
2881 'headers' => [
2882 'Content-Type' => 'application/json',
2883 'Authorization' => 'Bearer ' . $api_key,
2884 ],
2885 'body' => wp_json_encode([
2886 'model' => $model,
2887 'messages' => [
2888 ['role' => 'system', 'content' => $system_prompt],
2889 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2890 ],
2891 'temperature' => 0.2,
2892 'max_tokens' => 20,
2893 ]),
2894 'method' => 'POST',
2895 'timeout' => 15,
2896 ];
2897
2898 $response = wp_remote_post($url, $args);
2899 if (is_wp_error($response)) {
2900 return sanitize_text_field($user_query);
2901 }
2902
2903 $body = json_decode(wp_remote_retrieve_body($response), true);
2904 if (isset($body['choices'][0]['message']['content'])) {
2905 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2906 }
2907
2908 return sanitize_text_field($user_query);
2909 }
2910
2911 /**
2912 * Interpret query using DeepSeek models
2913 */
2914 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
2915 $url = 'https://api.deepseek.com/v1/chat/completions';
2916
2917 $args = [
2918 'headers' => [
2919 'Content-Type' => 'application/json',
2920 'Authorization' => 'Bearer ' . $api_key,
2921 ],
2922 'body' => wp_json_encode([
2923 'model' => $model,
2924 'messages' => [
2925 ['role' => 'system', 'content' => $system_prompt],
2926 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2927 ],
2928 'temperature' => 0.2,
2929 'max_tokens' => 20,
2930 ]),
2931 'method' => 'POST',
2932 'timeout' => 15,
2933 ];
2934
2935 $response = wp_remote_post($url, $args);
2936 if (is_wp_error($response)) {
2937 return sanitize_text_field($user_query);
2938 }
2939
2940 $body = json_decode(wp_remote_retrieve_body($response), true);
2941 if (isset($body['choices'][0]['message']['content'])) {
2942 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2943 }
2944
2945 return sanitize_text_field($user_query);
2946 }
2947
2948 //very good
2949 private function add_email_to_loops($email) {
2950 // Sanitize the email
2951 $email = sanitize_email($email);
2952
2953 // Retrieve and sanitize options
2954 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
2955 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
2956
2957 // Check for missing API key or mailing list ID
2958 if (empty($api_key) || empty($mailing_list_id)) {
2959 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
2960 return;
2961 }
2962
2963 $data = array(
2964 'email' => $email,
2965 'subscribed' => true,
2966 'source' => __('MxChat AI Chatbot', 'mxchat'),
2967 'mailingLists' => array($mailing_list_id => true),
2968 );
2969
2970 $url = 'https://app.loops.so/api/v1/contacts/create';
2971 $args = array(
2972 'body' => wp_json_encode($data),
2973 'headers' => array(
2974 'Authorization' => 'Bearer ' . $api_key,
2975 'Content-Type' => 'application/json',
2976 ),
2977 'method' => 'POST',
2978 'timeout' => 45,
2979 );
2980
2981 $response = wp_remote_post($url, $args);
2982
2983 // Handle errors in the API request
2984 if (is_wp_error($response)) {
2985 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
2986 return;
2987 }
2988
2989 // Check for non-200 HTTP responses
2990 $response_code = wp_remote_retrieve_response_code($response);
2991 if ($response_code != 200) {
2992 $response_body = wp_remote_retrieve_body($response);
2993 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
2994 }
2995 }
2996
2997 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
2998 // Get the maximum number of pages allowed from admin settings
2999 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3000
3001 // Retrieve options for dynamic texts
3002 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
3003 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
3004 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
3005
3006 // Check for explicit request for new PDF
3007 $new_pdf_requested = stripos($message, 'new') !== false ||
3008 stripos($message, 'another') !== false ||
3009 stripos($message, 'different') !== false;
3010
3011 // If user mentions adding/reading a PDF, set waiting flag
3012 if (stripos($message, 'pdf') !== false ||
3013 stripos($message, 'document') !== false ||
3014 stripos($message, 'read') !== false) {
3015 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3016 $this->fallbackResponse['text'] = $trigger_text;
3017 return;
3018 }
3019
3020 // If we're waiting for a URL or user requested new PDF
3021 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3022 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3023 // Process URL... (rest of your existing URL processing code)
3024 } else {
3025 $this->fallbackResponse['text'] = $trigger_text;
3026 }
3027 return;
3028 }
3029
3030 // Default to proceeding with conversation if no specific PDF action is needed
3031 $this->fallbackResponse['text'] = '';
3032 }
3033
3034
3035 /**
3036 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3037 */
3038 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3039 // CLEAR DEBUG LOGGING
3040 //error_log("=== MXCHAT PDF PROCESSING START ===");
3041 //error_log("PDF Source: " . $pdf_source);
3042 //error_log("Max Pages: " . $max_pages);
3043 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3044
3045 // Check if Advanced Claude Toolbar is available and enabled
3046 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3047 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3048
3049 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3050 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3051
3052 if ($claude_available && $claude_enabled) {
3053 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3054
3055 // Attempt Claude processing first
3056 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3057
3058 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
3059 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
3060 //error_log("Claude returned " . count($claude_result) . " processed pages");
3061
3062 // Log first page details for verification
3063 if (isset($claude_result[0])) {
3064 $first_page = $claude_result[0];
3065 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
3066 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
3067 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
3068 }
3069
3070 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
3071 return $claude_result;
3072 } else {
3073 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
3074 //error_log("Claude result type: " . gettype($claude_result));
3075 if (is_array($claude_result)) {
3076 //error_log("Claude result count: " . count($claude_result));
3077 }
3078 }
3079 }
3080
3081 // Fallback to basic processing
3082 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
3083
3084 $upload_dir = wp_upload_dir();
3085 $temp_file = null;
3086
3087 try {
3088 // Your existing basic processing code here...
3089 // (I'll include the key parts with debug logging)
3090
3091 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
3092 //error_log("Downloading PDF from URL...");
3093
3094 // SECURITY FIX: Validate URL before processing
3095 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
3096 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
3097 return false;
3098 }
3099
3100 $temp_file = wp_tempnam($pdf_source);
3101
3102 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
3103 $response = wp_safe_remote_get($pdf_source, [
3104 'timeout' => 60,
3105 'headers' => ['User-Agent' => 'MxChat PDF Processor']
3106 ]);
3107
3108 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
3109 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
3110 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
3111 return false;
3112 }
3113
3114 global $wp_filesystem;
3115 if (empty($wp_filesystem)) {
3116 require_once ABSPATH . 'wp-admin/includes/file.php';
3117 WP_Filesystem();
3118 }
3119 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
3120 //error_log("✅ PDF downloaded successfully");
3121 } else {
3122 $temp_file = $pdf_source;
3123 //error_log("Using local PDF file: " . $temp_file);
3124 }
3125
3126 // Parse PDF
3127 //error_log("Parsing PDF with basic parser...");
3128 mxchat_load_pdf_parser();
3129 $parser = new \Smalot\PdfParser\Parser();
3130 $pdf = $parser->parseFile($temp_file);
3131 $pages = $pdf->getPages();
3132
3133 //error_log("PDF contains " . count($pages) . " pages");
3134
3135 if (count($pages) > $max_pages) {
3136 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
3137 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
3138 unlink($temp_file);
3139 }
3140 return 'too_many_pages';
3141 }
3142
3143 $embeddings = [];
3144 $processed_pages = 0;
3145
3146 foreach ($pages as $page_number => $page) {
3147 $text = $page->getText();
3148
3149 if (empty(trim($text))) {
3150 //error_log("Skipping empty page: " . ($page_number + 1));
3151 continue;
3152 }
3153
3154 $text = $this->mxchat_clean_text($text);
3155
3156 $embedding = $this->mxchat_generate_embedding(
3157 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
3158 $this->options['api_key']
3159 );
3160
3161 if ($embedding) {
3162 $embeddings[] = [
3163 'page_number' => $page_number + 1,
3164 'embedding' => $embedding,
3165 'text' => $text,
3166 'enhanced' => false, // CLEARLY MARK AS BASIC
3167 'processing_method' => 'basic_pdf_parser'
3168 ];
3169 $processed_pages++;
3170 }
3171 }
3172
3173 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
3174
3175 // Cleanup
3176 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3177 unlink($temp_file);
3178 }
3179
3180 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
3181 return $embeddings;
3182
3183 } catch (\Exception $e) {
3184 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
3185 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3186 unlink($temp_file);
3187 }
3188 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
3189 return false;
3190 }
3191 }
3192
3193
3194 /**
3195 * Validate PDF URL for security
3196 * Prevents SSRF attacks by blocking dangerous URLs
3197 */
3198
3199 private function mxchat_is_safe_pdf_url($url) {
3200 // Use WordPress core function for comprehensive validation
3201 // This blocks localhost, private IPs, and reserved IP ranges
3202 $validated_url = wp_http_validate_url($url);
3203
3204 if ($validated_url === false) {
3205 return false;
3206 }
3207
3208 // Additional check: only allow HTTP/HTTPS schemes
3209 $parsed = parse_url($url);
3210 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
3211 return false;
3212 }
3213
3214 return true;
3215 }
3216
3217
3218 private function mxchat_clean_text($text) {
3219 // Remove excessive whitespace
3220 $text = preg_replace('/\s+/', ' ', $text);
3221
3222 // Remove control characters except newlines and tabs
3223 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
3224
3225 // Normalize line endings
3226 $text = str_replace(["\r\n", "\r"], "\n", $text);
3227
3228 // Trim whitespace
3229 $text = trim($text);
3230
3231 return $text;
3232 }
3233
3234 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
3235 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
3236
3237 $most_relevant = null;
3238 $highest_similarity = -INF;
3239
3240 foreach ($embeddings as $page_data) {
3241 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
3242
3243 if ($similarity > $highest_similarity) {
3244 $highest_similarity = $similarity;
3245 $most_relevant = $page_data['page_number'];
3246 }
3247 }
3248
3249 if (!is_null($most_relevant)) {
3250 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
3251 return array_filter($embeddings, function ($page) use ($page_numbers) {
3252 return in_array($page['page_number'], $page_numbers);
3253 });
3254 }
3255
3256 return [];
3257 }
3258
3259
3260 public function handle_pdf_upload() {
3261 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3262
3263 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
3264 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
3265 return;
3266 }
3267
3268 // SECURITY FIX: Check if PDF uploads are enabled in settings
3269 $options = get_option('mxchat_options', array());
3270 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
3271
3272 if ($show_pdf_button !== 'on') {
3273 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
3274 return;
3275 }
3276
3277 $file = $_FILES['pdf_file'];
3278 $session_id = sanitize_text_field($_POST['session_id']);
3279 $original_filename = sanitize_text_field($file['name']);
3280
3281 // SECURITY FIX: Verify session ownership before allowing upload
3282 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
3283 $session_owner = get_option("mxchat_session_owner_{$session_id}");
3284
3285 if ($session_owner && $session_owner !== $current_user_identifier) {
3286 wp_send_json_error(esc_html__('Unauthorized access.', 'mxchat'));
3287 return;
3288 }
3289
3290 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
3291 if ($file_type['type'] !== 'application/pdf') {
3292 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
3293 return;
3294 }
3295
3296 $upload_dir = wp_upload_dir();
3297
3298 // SECURITY FIX: Generate random filename without exposing session_id
3299 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
3300 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
3301 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
3302
3303 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
3304 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
3305 return;
3306 }
3307
3308 $this->clear_pdf_transients($session_id);
3309
3310 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3311 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
3312
3313 if ($embeddings === 'too_many_pages') {
3314 unlink($pdf_path);
3315 $error_message = sprintf(
3316 $this->options['pdf_intent_error_text'] ??
3317 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
3318 $max_pages
3319 );
3320 wp_send_json_error($error_message);
3321 return;
3322 }
3323
3324 if ($embeddings === false || empty($embeddings)) {
3325 unlink($pdf_path);
3326 $error_message = $this->options['pdf_intent_error_text'] ??
3327 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
3328 wp_send_json_error($error_message);
3329 return;
3330 }
3331
3332 if (!empty($embeddings)) {
3333 // Store the mapping between session and the random filename
3334 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
3335 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
3336 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
3337 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
3338
3339 $success_message = $this->options['pdf_intent_success_text'] ??
3340 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
3341
3342 wp_send_json_success([
3343 'message' => $success_message,
3344 'filename' => $original_filename
3345 ]);
3346 return;
3347 }
3348
3349 unlink($pdf_path);
3350 $error_message = $this->options['pdf_intent_error_text'] ??
3351 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
3352 wp_send_json_error($error_message);
3353 return;
3354 }
3355 public function handle_pdf_remove() {
3356 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3357
3358 if (empty($_POST['session_id'])) {
3359 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
3360 wp_die();
3361 }
3362
3363 $session_id = sanitize_text_field($_POST['session_id']);
3364 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
3365
3366 if ($pdf_path && file_exists($pdf_path)) {
3367 unlink($pdf_path);
3368 }
3369
3370 $this->clear_pdf_transients($session_id);
3371
3372 wp_send_json_success([
3373 'message' => esc_html__('PDF removed successfully.', 'mxchat')
3374 ]);
3375 wp_die();
3376 }
3377
3378
3379 function mxchat_fetch_new_messages() {
3380 $session_id = sanitize_text_field($_POST['session_id']);
3381 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
3382 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
3383 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
3384
3385 if (empty($session_id)) {
3386 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
3387 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
3388 wp_die();
3389 }
3390
3391 $history = get_option("mxchat_history_{$session_id}", []);
3392
3393 //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
3394 //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
3395 //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
3396 //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
3397
3398 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
3399 //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
3400
3401 // If persistence is enabled, show all new messages
3402 if ($persistence_enabled) {
3403 $has_id = !empty($message['id']);
3404 $is_agent = $message['role'] === 'agent';
3405
3406 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
3407 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
3408 $is_newer = true;
3409 } else {
3410 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
3411 }
3412
3413 //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
3414
3415 return $has_id && $is_newer && $is_agent;
3416 }
3417
3418 // If persistence is disabled, only show messages after initial timestamp
3419 return !empty($message['id']) &&
3420 $message['role'] === 'agent' &&
3421 $message['timestamp'] > $initial_timestamp;
3422 });
3423
3424 //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
3425
3426 // Include current chat mode so frontend can detect agent→AI transitions
3427 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
3428
3429 wp_send_json_success([
3430 'new_messages' => array_values($new_messages),
3431 'chat_mode' => $chat_mode
3432 ]);
3433 wp_die();
3434 }
3435 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
3436 // First check if live agents are available
3437 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
3438 if ($live_agent_available !== 'on') {
3439 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3440 $this->fallbackResponse = [
3441 'text' => $away_message,
3442 'html' => '',
3443 'images' => [],
3444 'chat_mode' => 'ai'
3445 ];
3446 wp_send_json([
3447 'text' => $away_message,
3448 'html' => '',
3449 'chat_mode' => 'ai',
3450 'session_id' => $session_id
3451 ]);
3452 wp_die();
3453 }
3454
3455 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3456
3457 if (empty($slack_bot_token)) {
3458 return false;
3459 }
3460
3461 // Check if channel already exists for this session
3462 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3463
3464 if (empty($channel_id)) {
3465 // Create new channel with session ID as name
3466 $channel_name = $this->generate_channel_name($session_id);
3467
3468 //error_log("Attempting to create channel: $channel_name");
3469
3470 $response = wp_remote_post('https://slack.com/api/conversations.create', [
3471 'headers' => [
3472 'Content-Type' => 'application/json',
3473 'Authorization' => 'Bearer ' . $slack_bot_token
3474 ],
3475 'body' => json_encode([
3476 'name' => $channel_name,
3477 'is_private' => false // Public channel - anyone in workspace can join
3478 ])
3479 ]);
3480
3481 if (!is_wp_error($response)) {
3482 $response_body = wp_remote_retrieve_body($response);
3483 $response_data = json_decode($response_body, true);
3484
3485 //error_log("Channel creation response: " . $response_body);
3486
3487 if (isset($response_data['ok']) && $response_data['ok']) {
3488 $channel_id = $response_data['channel']['id'];
3489 $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
3490 //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
3491 update_option("mxchat_channel_{$session_id}", $channel_id);
3492
3493 // Auto-invite agents to the channel
3494 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
3495
3496 if (!empty($agent_user_ids)) {
3497 // Parse user IDs (one per line)
3498 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
3499
3500 foreach ($user_ids as $user_id_to_invite) {
3501 //error_log("Inviting user to channel: $user_id_to_invite");
3502
3503 $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
3504 'headers' => [
3505 'Content-Type' => 'application/json',
3506 'Authorization' => 'Bearer ' . $slack_bot_token
3507 ],
3508 'body' => json_encode([
3509 'channel' => $channel_id,
3510 'users' => $user_id_to_invite
3511 ])
3512 ]);
3513
3514 if (!is_wp_error($invite_response)) {
3515 $invite_body = wp_remote_retrieve_body($invite_response);
3516 $invite_data = json_decode($invite_body, true);
3517 //error_log("Invite response for $user_id_to_invite: " . $invite_body);
3518
3519 if (isset($invite_data['ok']) && $invite_data['ok']) {
3520 //error_log("Successfully invited user $user_id_to_invite to channel");
3521 } else {
3522 //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
3523 }
3524 } else {
3525 //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
3526 }
3527 }
3528 } else {
3529 //error_log("No agent user IDs configured for auto-invite");
3530 }
3531 } else {
3532 //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
3533 }
3534 } else {
3535 //error_log("WP Error creating channel: " . $response->get_error_message());
3536 }
3537
3538 if (empty($channel_id)) {
3539 return false; // Failed to create channel
3540 }
3541 }
3542
3543 // Get recent chat history
3544 $history = get_option("mxchat_history_{$session_id}", []);
3545 $recent_history = array_slice($history, -5);
3546
3547 // Format conversation context
3548 $conversation_context = "";
3549 if (!empty($recent_history)) {
3550 $conversation_context = "*Recent Conversation:*\n";
3551 foreach ($recent_history as $hist_message) {
3552 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
3553 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
3554 }
3555 $conversation_context .= "\n";
3556 }
3557
3558 update_option("mxchat_mode_{$session_id}", 'agent');
3559
3560 // Send message to channel
3561 $channel_message = "🔔 *New Live Agent Request*\n\n";
3562 $channel_message .= "*Session ID:* `{$session_id}`\n";
3563 $channel_message .= "*User ID:* `{$user_id}`\n\n";
3564
3565 if (!empty($conversation_context)) {
3566 $channel_message .= $conversation_context;
3567 }
3568
3569 $channel_message .= "*Current Message:*\n{$message}\n\n";
3570 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
3571
3572 wp_remote_post('https://slack.com/api/chat.postMessage', [
3573 'headers' => [
3574 'Content-Type' => 'application/json',
3575 'Authorization' => 'Bearer ' . $slack_bot_token
3576 ],
3577 'body' => json_encode([
3578 'channel' => $channel_id,
3579 'text' => $channel_message,
3580 'mrkdwn' => true
3581 ])
3582 ]);
3583
3584 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
3585 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3586
3587 $this->fallbackResponse = [
3588 'text' => $success_message,
3589 'html' => '',
3590 'images' => [],
3591 'chat_mode' => 'agent'
3592 ];
3593
3594 wp_send_json([
3595 'success' => true,
3596 'text' => $success_message,
3597 'html' => '',
3598 'chat_mode' => 'agent',
3599 'session_id' => $session_id,
3600 'fallbackResponse' => $this->fallbackResponse
3601 ]);
3602 wp_die();
3603 }
3604
3605 private function generate_channel_name($session_id) {
3606 $email = null;
3607 $name = null;
3608
3609 // 1. First priority: Check if user is logged in and get their info
3610 if (is_user_logged_in()) {
3611 $current_user = wp_get_current_user();
3612 if (!empty($current_user->user_email)) {
3613 $email = $current_user->user_email;
3614 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
3615 }
3616 if (!empty($current_user->display_name)) {
3617 $name = $current_user->display_name;
3618 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
3619 }
3620 }
3621
3622 // 2. Second priority: Check for saved email/name from "require email to chat" option
3623 if (empty($email)) {
3624 $email_option_key = "mxchat_email_{$session_id}";
3625 $saved_email = get_option($email_option_key);
3626 if (!empty($saved_email)) {
3627 $email = $saved_email;
3628 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
3629 }
3630 }
3631
3632 if (empty($name)) {
3633 $name_option_key = "mxchat_name_{$session_id}";
3634 $saved_name = get_option($name_option_key);
3635 if (!empty($saved_name)) {
3636 $name = $saved_name;
3637 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
3638 }
3639 }
3640
3641 // 3. Third priority: Check existing chat transcript for email/name
3642 if (empty($email) || empty($name)) {
3643 global $wpdb;
3644 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3645 $existing_data = $wpdb->get_row($wpdb->prepare(
3646 "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",
3647 $session_id
3648 ));
3649
3650 if ($existing_data) {
3651 if (empty($email) && !empty($existing_data->user_email)) {
3652 $email = $existing_data->user_email;
3653 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
3654 }
3655 if (empty($name) && !empty($existing_data->user_name)) {
3656 $name = $existing_data->user_name;
3657 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
3658 }
3659 }
3660 }
3661
3662 // 4. Generate channel name based on priority: Name > Email > Session ID
3663 $channel_name = '';
3664
3665 if (!empty($name)) {
3666 // Convert name to valid Slack channel name
3667 $base_name = strtolower(trim($name));
3668 // Replace spaces and invalid characters
3669 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
3670 $base_name = preg_replace('/\s+/', '-', $base_name);
3671 $base_name = trim($base_name, '-');
3672
3673 // Get last 4 characters of session ID for uniqueness
3674 $session_suffix = substr($session_id, -4);
3675 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
3676
3677 // Slack channel names have a 21 character limit
3678 if (strlen($channel_name) > 21) {
3679 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
3680 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
3681 $truncated_name = substr($base_name, 0, $available_space);
3682 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
3683 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
3684 }
3685
3686 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
3687
3688 } elseif (!empty($email)) {
3689 // Convert email to valid Slack channel name (your existing logic)
3690 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
3691 // Remove any remaining invalid characters
3692 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
3693 // Ensure it doesn't end with a hyphen
3694 $channel_name = rtrim($channel_name, '-');
3695 // Slack channel names have a 21 character limit, so truncate if needed
3696 if (strlen($channel_name) > 21) {
3697 $channel_name = substr($channel_name, 0, 21);
3698 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
3699 }
3700
3701 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
3702
3703 } else {
3704 // Fallback to session ID if no name or email found
3705 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
3706 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
3707 }
3708
3709 // Final validation - ensure channel name meets Slack requirements
3710 if (strlen($channel_name) > 21) {
3711 $channel_name = substr($channel_name, 0, 21);
3712 $channel_name = rtrim($channel_name, '-');
3713 }
3714
3715 //error_log("[DEBUG] Generated channel name: {$channel_name}");
3716 return $channel_name;
3717 }
3718
3719 /**
3720 * Telegram Live Agent Handover
3721 * Creates a forum topic in the Telegram group and notifies agents
3722 */
3723 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
3724 // Check if Telegram agents are available
3725 $telegram_available = $this->options['telegram_status'] ?? 'off';
3726 if ($telegram_available !== 'on') {
3727 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3728 $this->fallbackResponse = [
3729 'text' => $away_message,
3730 'html' => '',
3731 'images' => [],
3732 'chat_mode' => 'ai'
3733 ];
3734 wp_send_json([
3735 'text' => $away_message,
3736 'html' => '',
3737 'chat_mode' => 'ai',
3738 'session_id' => $session_id
3739 ]);
3740 wp_die();
3741 }
3742
3743 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3744 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
3745
3746 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
3747 return false;
3748 }
3749
3750 // Check if topic already exists for this session
3751 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3752
3753 if (empty($topic_id)) {
3754 // Generate topic name
3755 $topic_name = $this->generate_telegram_topic_name($session_id);
3756
3757 // Random icon color (Telegram forum topic colors)
3758 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
3759 $icon_color = $icon_colors[array_rand($icon_colors)];
3760
3761 // Create forum topic
3762 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
3763 'headers' => ['Content-Type' => 'application/json'],
3764 'body' => json_encode([
3765 'chat_id' => $telegram_group_id,
3766 'name' => $topic_name,
3767 'icon_color' => $icon_color
3768 ])
3769 ]);
3770
3771 if (!is_wp_error($response)) {
3772 $response_body = wp_remote_retrieve_body($response);
3773 $response_data = json_decode($response_body, true);
3774
3775 if (isset($response_data['ok']) && $response_data['ok']) {
3776 $topic_id = $response_data['result']['message_thread_id'];
3777 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
3778 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
3779 }
3780 }
3781
3782 if (empty($topic_id)) {
3783 return false; // Failed to create topic
3784 }
3785 }
3786
3787 // Get recent chat history
3788 $history = get_option("mxchat_history_{$session_id}", []);
3789 $recent_history = array_slice($history, -5);
3790
3791 // Format conversation context for Telegram (HTML format)
3792 $conversation_context = "";
3793 if (!empty($recent_history)) {
3794 $conversation_context = "<b>Recent Conversation:</b>\n";
3795 foreach ($recent_history as $hist_message) {
3796 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
3797 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
3798 $conversation_context .= "{$role_display}: {$escaped_content}\n";
3799 }
3800 $conversation_context .= "\n";
3801 }
3802
3803 // Get user info
3804 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
3805 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
3806
3807 // Update session mode
3808 update_option("mxchat_mode_{$session_id}", 'agent');
3809
3810 // Send initial message to topic
3811 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3812 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
3813 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
3814 $topic_message .= "<b>User:</b> {$user_name}\n";
3815 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
3816
3817 if (!empty($conversation_context)) {
3818 $topic_message .= $conversation_context;
3819 }
3820
3821 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
3822 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
3823 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
3824
3825 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3826 'headers' => ['Content-Type' => 'application/json'],
3827 'body' => json_encode([
3828 'chat_id' => $telegram_group_id,
3829 'message_thread_id' => $topic_id,
3830 'text' => $topic_message,
3831 'parse_mode' => 'HTML'
3832 ])
3833 ]);
3834
3835 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
3836 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3837
3838 $this->fallbackResponse = [
3839 'text' => $success_message,
3840 'html' => '',
3841 'images' => [],
3842 'chat_mode' => 'agent'
3843 ];
3844
3845 wp_send_json([
3846 'success' => true,
3847 'text' => $success_message,
3848 'html' => '',
3849 'chat_mode' => 'agent',
3850 'session_id' => $session_id,
3851 'fallbackResponse' => $this->fallbackResponse
3852 ]);
3853 wp_die();
3854 }
3855
3856 /**
3857 * Generate topic name for Telegram forum
3858 */
3859 private function generate_telegram_topic_name($session_id) {
3860 $name = null;
3861 $email = null;
3862
3863 // Check logged in user
3864 if (is_user_logged_in()) {
3865 $current_user = wp_get_current_user();
3866 if (!empty($current_user->display_name)) {
3867 $name = $current_user->display_name;
3868 }
3869 if (!empty($current_user->user_email)) {
3870 $email = $current_user->user_email;
3871 }
3872 }
3873
3874 // Check session data
3875 if (empty($name)) {
3876 $name = get_option("mxchat_name_{$session_id}");
3877 }
3878 if (empty($email)) {
3879 $email = get_option("mxchat_email_{$session_id}");
3880 }
3881
3882 // Generate topic name
3883 $session_suffix = substr($session_id, -6);
3884
3885 if (!empty($name)) {
3886 // Clean name for topic (max 128 chars in Telegram)
3887 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
3888 $clean_name = trim($clean_name);
3889 if (strlen($clean_name) > 50) {
3890 $clean_name = substr($clean_name, 0, 50);
3891 }
3892 return "Chat - {$clean_name} ({$session_suffix})";
3893 } elseif (!empty($email)) {
3894 // Use email prefix
3895 $email_prefix = explode('@', $email)[0];
3896 if (strlen($email_prefix) > 30) {
3897 $email_prefix = substr($email_prefix, 0, 30);
3898 }
3899 return "Chat - {$email_prefix} ({$session_suffix})";
3900 }
3901
3902 return "Chat - {$session_suffix}";
3903 }
3904
3905 /**
3906 * Send user message to Telegram agent
3907 */
3908 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
3909 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3910 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3911 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3912
3913 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
3914 return false;
3915 }
3916
3917 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3918 $user_message = "👤 <b>User:</b> {$escaped_message}";
3919
3920 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3921 'headers' => ['Content-Type' => 'application/json'],
3922 'body' => json_encode([
3923 'chat_id' => $group_id,
3924 'message_thread_id' => $topic_id,
3925 'text' => $user_message,
3926 'parse_mode' => 'HTML'
3927 ])
3928 ]);
3929
3930 return !is_wp_error($response);
3931 }
3932
3933 /**
3934 * Handle incoming Telegram webhook
3935 */
3936 public function handle_telegram_webhook(WP_REST_Request $request) {
3937 $body = $request->get_body();
3938 $data = json_decode($body, true);
3939
3940 //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
3941
3942 // Handle message events from forum topics
3943 if (isset($data['message'])) {
3944 $message_data = $data['message'];
3945
3946 // Skip if not from a forum topic
3947 if (!isset($message_data['message_thread_id'])) {
3948 //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
3949 return new WP_REST_Response(['ok' => true]);
3950 }
3951
3952 // Skip bot messages
3953 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
3954 //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
3955 return new WP_REST_Response(['ok' => true]);
3956 }
3957
3958 $chat_id = $message_data['chat']['id'] ?? '';
3959 $topic_id = $message_data['message_thread_id'];
3960 $message_text = $message_data['text'] ?? '';
3961 $message_id = $message_data['message_id'] ?? '';
3962 $from = $message_data['from'] ?? [];
3963 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
3964 if (empty($agent_name)) {
3965 $agent_name = $from['username'] ?? 'Agent';
3966 }
3967
3968 //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
3969
3970 // Skip empty messages
3971 if (empty($message_text)) {
3972 //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
3973 return new WP_REST_Response(['ok' => true]);
3974 }
3975
3976 // Find session ID by topic ID - cast to string for comparison
3977 global $wpdb;
3978 $topic_id_str = strval($topic_id);
3979 $session_option = $wpdb->get_var(
3980 $wpdb->prepare(
3981 "SELECT option_name FROM {$wpdb->options}
3982 WHERE option_name LIKE %s
3983 AND option_value = %s",
3984 'mxchat_telegram_topic_%',
3985 $topic_id_str
3986 )
3987 );
3988
3989 //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
3990
3991 if ($session_option) {
3992 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
3993 //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
3994
3995 // Verify the group ID matches
3996 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3997 //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
3998
3999 if (strval($stored_group_id) != strval($chat_id)) {
4000 //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
4001 return new WP_REST_Response(['ok' => true]);
4002 }
4003
4004 // Check for closure commands
4005 $lower_text = strtolower(trim($message_text));
4006 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
4007 //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
4008 // End the live agent session
4009 update_option("mxchat_mode_{$session_id}", 'ai');
4010
4011 // Save disconnect message
4012 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
4013 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
4014
4015 // Notify in Telegram
4016 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4017 if (!empty($telegram_bot_token)) {
4018 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4019 'headers' => ['Content-Type' => 'application/json'],
4020 'body' => json_encode([
4021 'chat_id' => $chat_id,
4022 'message_thread_id' => $topic_id,
4023 'text' => "✅ Session closed. User returned to AI chatbot.",
4024 'parse_mode' => 'HTML'
4025 ])
4026 ]);
4027
4028 // Optionally close the topic
4029 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
4030 'headers' => ['Content-Type' => 'application/json'],
4031 'body' => json_encode([
4032 'chat_id' => $chat_id,
4033 'message_thread_id' => $topic_id
4034 ])
4035 ]);
4036 }
4037
4038 return new WP_REST_Response(['ok' => true]);
4039 }
4040
4041 // Deduplicate messages
4042 $message_key = md5($session_id . $message_id . $message_text);
4043 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
4044
4045 if (in_array($message_key, $processed_messages)) {
4046 //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
4047 return new WP_REST_Response(['ok' => true]);
4048 }
4049
4050 $processed_messages[] = $message_key;
4051 if (count($processed_messages) > 50) {
4052 $processed_messages = array_slice($processed_messages, -50);
4053 }
4054 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4055
4056 // Save the agent message - format with agent name prefix for proper parsing
4057 $formatted_message = "Agent: {$agent_name} - {$message_text}";
4058 //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
4059
4060 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
4061
4062 // Verify the message was saved to history
4063 $history = get_option("mxchat_history_{$session_id}", []);
4064 $last_message = end($history);
4065 //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
4066
4067 // Send confirmation back to Telegram
4068 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4069 if (!empty($telegram_bot_token)) {
4070 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
4071 if (!get_transient($confirm_key)) {
4072 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4073 'headers' => ['Content-Type' => 'application/json'],
4074 'body' => json_encode([
4075 'chat_id' => $chat_id,
4076 'message_thread_id' => $topic_id,
4077 'text' => "✅ <i>Message sent to user</i>",
4078 'parse_mode' => 'HTML',
4079 'reply_to_message_id' => $message_id
4080 ])
4081 ]);
4082 set_transient($confirm_key, true, 300);
4083 }
4084 }
4085 } else {
4086 //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
4087 }
4088 } else {
4089 //error_log('[MxChat Telegram DEBUG] No message in webhook data');
4090 }
4091
4092 return new WP_REST_Response(['ok' => true]);
4093 }
4094
4095 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
4096 // Check if this is a Telegram agent session
4097 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4098 if (!empty($telegram_topic_id)) {
4099 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
4100 }
4101
4102 // Otherwise, try Slack
4103 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4104 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4105
4106 if (empty($slack_bot_token) || empty($channel_id)) {
4107 return false;
4108 }
4109
4110 $user_message = "💬 *User:* {$message}";
4111
4112 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4113 'headers' => [
4114 'Content-Type' => 'application/json',
4115 'Authorization' => 'Bearer ' . $slack_bot_token
4116 ],
4117 'body' => json_encode([
4118 'channel' => $channel_id,
4119 'text' => $user_message,
4120 'mrkdwn' => true
4121 ])
4122 ]);
4123
4124 return !is_wp_error($response);
4125 }
4126 public function handle_slack_interaction(WP_REST_Request $request) {
4127 //error_log('Received Slack interaction');
4128
4129 $payload = json_decode($request->get_param('payload'), true);
4130 //error_log('Payload: ' . print_r($payload, true));
4131
4132 // Handle button click
4133 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
4134 $session_id = $payload['actions'][0]['value'];
4135 $trigger_id = $payload['trigger_id'];
4136
4137 // Get Bot Token from settings
4138 $slack_token = $this->options['live_agent_bot_token'] ?? '';
4139
4140 if (empty($slack_token)) {
4141 //error_log('Slack Bot Token not configured');
4142 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
4143 }
4144 $response = wp_remote_post('https://slack.com/api/views.open', [
4145 'headers' => [
4146 'Content-Type' => 'application/json',
4147 'Authorization' => 'Bearer ' . $slack_token
4148 ],
4149 'body' => json_encode([
4150 'trigger_id' => $trigger_id,
4151 'view' => [
4152 'type' => 'modal',
4153 'callback_id' => 'reply_modal',
4154 'title' => [
4155 'type' => 'plain_text',
4156 'text' => __('Reply to User', 'mxchat')
4157 ],
4158 'submit' => [
4159 'type' => 'plain_text',
4160 'text' => __('Send', 'mxchat')
4161 ],
4162 'close' => [
4163 'type' => 'plain_text',
4164 'text' => __('Cancel', 'mxchat')
4165 ],
4166 'blocks' => [
4167 [
4168 'type' => 'input',
4169 'block_id' => 'reply_block',
4170 'label' => [
4171 'type' => 'plain_text',
4172 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
4173 ],
4174 'element' => [
4175 'type' => 'plain_text_input',
4176 'action_id' => 'message',
4177 'multiline' => true,
4178 'placeholder' => [
4179 'type' => 'plain_text',
4180 'text' => __('Type your message here...', 'mxchat')
4181 ]
4182 ]
4183 ]
4184 ],
4185 'private_metadata' => $session_id
4186 ]
4187 ])
4188 ]);
4189
4190 //error_log('Views.open response: ' . print_r($response, true));
4191
4192 // Return immediate acknowledgment
4193 return new WP_REST_Response(['ok' => true]);
4194 }
4195
4196 // Handle modal submission
4197 // Handle modal submission
4198 if ($payload['type'] === 'view_submission') {
4199 $session_id = $payload['view']['private_metadata'];
4200 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
4201
4202 // Save the message (keep the message_id but don't include in response)
4203 $this->mxchat_save_chat_message($session_id, 'agent', $message);
4204
4205 // Keep the original response format for Slack
4206 return new WP_REST_Response([
4207 'response_action' => 'clear'
4208 ]);
4209 }
4210
4211 // Default acknowledgment
4212 return new WP_REST_Response(['ok' => true]);
4213 }
4214 public function mxchat_handle_agent_response(WP_REST_Request $request) {
4215 //error_log('Received agent response request');
4216 //error_log('Request data: ' . print_r($request->get_params(), true));
4217 // //error_log('Raw body: ' . file_get_contents('php://input'));
4218
4219 // Get the data from Slack's slash command format
4220 $command_text = $request->get_param('text');
4221 // //error_log('Command text: ' . $command_text);
4222
4223 if (empty($command_text)) {
4224 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
4225 return new WP_REST_Response([
4226 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
4227 ], 400);
4228 }
4229
4230 // Split the command text into session_id and message
4231 $parts = explode(' ', $command_text, 2);
4232 if (count($parts) !== 2) {
4233 //error_log('Agent response error: Invalid command format');
4234 return new WP_REST_Response([
4235 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
4236 ], 400);
4237 }
4238
4239 $session_id = sanitize_text_field($parts[0]);
4240 $message = sanitize_text_field($parts[1]);
4241
4242 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
4243
4244 // Save the message
4245 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
4246
4247 if (!$message_id) {
4248 // //error_log('Failed to save agent message');
4249 return new WP_REST_Response([
4250 'error' => esc_html__('Failed to save message', 'mxchat')
4251 ], 500);
4252 }
4253
4254 // Return success response in Slack's expected format
4255 return new WP_REST_Response([
4256 'response_type' => 'in_channel',
4257 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
4258 ], 200);
4259 }
4260 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
4261 // Update mode to AI
4262 update_option("mxchat_mode_{$session_id}", 'ai');
4263
4264 // Clear any existing PDF context to start fresh
4265 $this->clear_pdf_transients($session_id);
4266
4267 // Set the response with explicit chat_mode
4268 $this->fallbackResponse = [
4269 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
4270 'html' => '',
4271 'images' => [],
4272 'chat_mode' => 'ai' // Ensure this is set
4273 ];
4274
4275 // Return the complete response array instead of just true
4276 return $this->fallbackResponse;
4277 }
4278
4279 public function handle_slack_messages(WP_REST_Request $request) {
4280 // Log the incoming request for debugging
4281 //error_log('Slack events request received: ' . $request->get_body());
4282
4283 $body = $request->get_body();
4284 $data = json_decode($body, true);
4285
4286 // Handle Slack URL verification
4287 if (isset($data['type']) && $data['type'] === 'url_verification') {
4288 //error_log('Slack URL verification challenge: ' . $data['challenge']);
4289 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
4290 }
4291
4292 // IMPORTANT: Handle Slack's event deduplication
4293 if (isset($data['event_id'])) {
4294 $event_id = $data['event_id'];
4295 $processed_events = get_transient('mxchat_slack_events') ?: [];
4296
4297 // Check if we've already processed this event
4298 if (in_array($event_id, $processed_events)) {
4299 //error_log("Duplicate event detected: $event_id");
4300 return new WP_REST_Response(['ok' => true]);
4301 }
4302
4303 // Add this event to processed list
4304 $processed_events[] = $event_id;
4305 // Keep only last 100 events to prevent memory issues
4306 if (count($processed_events) > 100) {
4307 $processed_events = array_slice($processed_events, -100);
4308 }
4309 // Store for 1 hour
4310 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
4311 }
4312
4313 // Handle message events
4314 if (isset($data['event']) && $data['event']['type'] === 'message') {
4315 $event = $data['event'];
4316
4317 // Skip bot messages and messages with subtypes (like bot_message)
4318 if (isset($event['bot_id']) || isset($event['subtype'])) {
4319 return new WP_REST_Response(['ok' => true]);
4320 }
4321
4322 // Additional check: Skip if this is a threaded reply to our confirmation
4323 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
4324 return new WP_REST_Response(['ok' => true]);
4325 }
4326
4327 $channel_id = $event['channel'];
4328 $message_text = $event['text'] ?? '';
4329 $message_ts = $event['ts'] ?? '';
4330
4331 // Find session ID by looking for matching channel
4332 global $wpdb;
4333 $session_option = $wpdb->get_var(
4334 $wpdb->prepare(
4335 "SELECT option_name FROM {$wpdb->options}
4336 WHERE option_name LIKE 'mxchat_channel_%'
4337 AND option_value = %s",
4338 $channel_id
4339 )
4340 );
4341
4342 if ($session_option) {
4343 $session_id = str_replace('mxchat_channel_', '', $session_option);
4344
4345 // Create a unique key for this specific message
4346 $message_key = md5($session_id . $message_ts . $message_text);
4347 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
4348
4349 // Check if we've already processed this exact message
4350 if (in_array($message_key, $processed_messages)) {
4351 //error_log("Duplicate message detected for session $session_id");
4352 return new WP_REST_Response(['ok' => true]);
4353 }
4354
4355 // Add to processed messages
4356 $processed_messages[] = $message_key;
4357 // Keep only last 50 messages per session
4358 if (count($processed_messages) > 50) {
4359 $processed_messages = array_slice($processed_messages, -50);
4360 }
4361 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4362
4363 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4364
4365 // Handle agent ending the chat — transfer back to AI
4366 // Format: "!endchat" or "!endchat <custom message to user>"
4367 if (preg_match('/^!endchat\b/i', trim($message_text))) {
4368 update_option("mxchat_mode_{$session_id}", 'ai');
4369
4370 // Extract custom message after !endchat, or use empty string
4371 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
4372
4373 // Send the agent's custom farewell message if provided
4374 if (!empty($custom_message)) {
4375 $this->mxchat_save_chat_message($session_id, 'agent', $custom_message);
4376 }
4377
4378 // Confirm in Slack channel
4379 if (!empty($slack_bot_token)) {
4380 wp_remote_post('https://slack.com/api/chat.postMessage', [
4381 'headers' => [
4382 'Content-Type' => 'application/json',
4383 'Authorization' => 'Bearer ' . $slack_bot_token
4384 ],
4385 'body' => json_encode([
4386 'channel' => $channel_id,
4387 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
4388 'mrkdwn' => true
4389 ])
4390 ]);
4391 }
4392
4393 return new WP_REST_Response(['ok' => true]);
4394 }
4395
4396 // Save the agent message
4397 $this->mxchat_save_chat_message($session_id, 'agent', $message_text);
4398
4399 // Send confirmation back to Slack (only once)
4400 if (!empty($slack_bot_token)) {
4401 // Use a transient to prevent duplicate confirmations
4402 $confirm_key = 'mxchat_confirm_' . $message_key;
4403 if (!get_transient($confirm_key)) {
4404 wp_remote_post('https://slack.com/api/chat.postMessage', [
4405 'headers' => [
4406 'Content-Type' => 'application/json',
4407 'Authorization' => 'Bearer ' . $slack_bot_token
4408 ],
4409 'body' => json_encode([
4410 'channel' => $channel_id,
4411 'text' => "✅ _Message sent to user_",
4412 'thread_ts' => $event['ts'] // Reply in thread
4413 ])
4414 ]);
4415 // Set transient to prevent duplicate confirmations
4416 set_transient($confirm_key, true, 300); // 5 minutes
4417 }
4418 }
4419 }
4420 }
4421
4422 return new WP_REST_Response(['ok' => true]);
4423 }
4424
4425 // For the word upload handler
4426 public function mxchat_handle_word_upload() {
4427 // Delegate to word handler
4428 $this->word_handler->mxchat_handle_word_upload();
4429 }
4430
4431 // For the word removal handler
4432 public function mxchat_handle_word_remove() {
4433 // Delegate to word handler
4434 $this->word_handler->mxchat_handle_word_remove();
4435 }
4436
4437 // For the word status check
4438 public function mxchat_check_word_status() {
4439 // Delegate to word handler
4440 $this->word_handler->mxchat_check_word_status();
4441 }
4442
4443
4444 private function mxchat_get_user_identifier() {
4445 return MxChat_User::mxchat_get_user_identifier();
4446 }
4447
4448 private function mxchat_generate_embedding($text, $api_key) {
4449 try {
4450 // Get options and selected model
4451 $options = get_option('mxchat_options');
4452 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4453
4454 // Determine endpoint and API key based on model
4455 if (strpos($selected_model, 'voyage') === 0) {
4456 $endpoint = 'https://api.voyageai.com/v1/embeddings';
4457 $api_key = $options['voyage_api_key'] ?? '';
4458
4459 // Check if Voyage API key is missing
4460 if (empty($api_key)) {
4461 //error_log('Voyage API key is missing');
4462 return [
4463 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
4464 'error_code' => 'missing_voyage_api_key'
4465 ];
4466 }
4467 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4468 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4469 $api_key = $options['gemini_api_key'] ?? '';
4470
4471 // Check if Gemini API key is missing
4472 if (empty($api_key)) {
4473 //error_log('Gemini API key is missing');
4474 return [
4475 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
4476 'error_code' => 'missing_gemini_api_key'
4477 ];
4478 }
4479 } else {
4480 $endpoint = 'https://api.openai.com/v1/embeddings';
4481 // Use the passed API key for OpenAI
4482
4483 // Check if OpenAI API key is missing
4484 if (empty($api_key)) {
4485 //error_log('OpenAI API key is missing');
4486 return [
4487 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
4488 'error_code' => 'missing_openai_api_key'
4489 ];
4490 }
4491 }
4492
4493 // Check if text is empty
4494 if (empty($text)) {
4495 //error_log('Empty text provided for embedding generation');
4496 return [
4497 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
4498 'error_code' => 'empty_embedding_text'
4499 ];
4500 }
4501
4502 // Prepare request body based on provider
4503 if (strpos($selected_model, 'gemini-embedding') === 0) {
4504 // Gemini API format
4505 $request_body = [
4506 'model' => 'models/' . $selected_model,
4507 'content' => [
4508 'parts' => [
4509 ['text' => $text]
4510 ]
4511 ],
4512 'outputDimensionality' => 1536
4513 ];
4514
4515 // Prepare headers for Gemini (API key as query parameter)
4516 $endpoint .= '?key=' . $api_key;
4517 $headers = [
4518 'Content-Type' => 'application/json'
4519 ];
4520 } else {
4521 // OpenAI/Voyage API format
4522 $request_body = [
4523 'input' => $text,
4524 'model' => $selected_model
4525 ];
4526
4527 // Add output_dimension for voyage-3-large
4528 if ($selected_model === 'voyage-3-large') {
4529 $request_body['output_dimension'] = 2048;
4530 }
4531
4532 // Prepare headers for OpenAI/Voyage
4533 $headers = [
4534 'Content-Type' => 'application/json',
4535 'Authorization' => 'Bearer ' . $api_key
4536 ];
4537 }
4538
4539 // Prepare request arguments
4540 $args = [
4541 'body' => wp_json_encode($request_body),
4542 'headers' => $headers,
4543 'timeout' => 60,
4544 'redirection' => 5,
4545 'blocking' => true,
4546 'httpversion' => '1.0',
4547 'sslverify' => true,
4548 ];
4549
4550 // Make the request
4551 $response = wp_remote_post($endpoint, $args);
4552
4553 // Handle WordPress errors
4554 if (is_wp_error($response)) {
4555 $error_message = $response->get_error_message();
4556 //error_log('Embedding Generation Error: ' . $error_message);
4557 return [
4558 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
4559 'error_code' => 'embedding_connection_error'
4560 ];
4561 }
4562
4563 // Check HTTP status code
4564 $status_code = wp_remote_retrieve_response_code($response);
4565 if ($status_code !== 200) {
4566 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4567
4568 $error_message = isset($response_body['error']['message'])
4569 ? $response_body['error']['message']
4570 : 'HTTP Error ' . $status_code;
4571
4572 $error_type = isset($response_body['error']['type'])
4573 ? $response_body['error']['type']
4574 : 'unknown';
4575
4576 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
4577
4578 // Handle specific error types
4579 switch ($error_type) {
4580 case 'invalid_request_error':
4581 if (strpos($error_message, 'API key') !== false) {
4582 return [
4583 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
4584 'error_code' => 'embedding_invalid_api_key'
4585 ];
4586 }
4587 break;
4588
4589 case 'authentication_error':
4590 return [
4591 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
4592 'error_code' => 'embedding_auth_error'
4593 ];
4594
4595 case 'rate_limit_exceeded':
4596 return [
4597 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
4598 'error_code' => 'embedding_rate_limit'
4599 ];
4600
4601 case 'quota_exceeded':
4602 return [
4603 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
4604 'error_code' => 'embedding_quota_exceeded'
4605 ];
4606 }
4607
4608 // Generic error fallback
4609 return [
4610 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
4611 'error_code' => 'embedding_api_error',
4612 'status_code' => $status_code
4613 ];
4614 }
4615
4616 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4617
4618 // Handle different response formats based on provider
4619 if (strpos($selected_model, 'gemini-embedding') === 0) {
4620 // Gemini API response format
4621 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
4622 return $response_body['embedding']['values'];
4623 } else {
4624 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
4625 return [
4626 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
4627 'error_code' => 'invalid_gemini_embedding_response'
4628 ];
4629 }
4630 } else {
4631 // OpenAI/Voyage API response format
4632 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
4633 return $response_body['data'][0]['embedding'];
4634 } else {
4635 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
4636 return [
4637 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
4638 'error_code' => 'invalid_embedding_response'
4639 ];
4640 }
4641 }
4642 } catch (Exception $e) {
4643 //error_log('Embedding Exception: ' . $e->getMessage());
4644 return [
4645 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
4646 'error_code' => 'embedding_exception'
4647 ];
4648 }
4649 }
4650
4651
4652 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
4653 //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
4654
4655 // Check for OpenAI Vector Store first (takes priority when enabled)
4656 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
4657
4658 if ($bot_vectorstore_config['use_vectorstore']) {
4659 // Get current model to verify it's an OpenAI model
4660 $bot_options = $this->get_bot_options($bot_id);
4661 $mxchat_options = get_option('mxchat_options', array());
4662 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
4663 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
4664
4665 if ($this->is_openai_chat_model($selected_model)) {
4666 //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
4667 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
4668 } else {
4669 //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
4670 }
4671 }
4672
4673 // Get bot-specific Pinecone configuration
4674 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4675
4676 // Debug: Log the Pinecone configuration
4677 //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
4678 //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
4679 //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
4680 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
4681 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
4682
4683 // Determine whether to use Pinecone based on bot configuration
4684 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
4685
4686 //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
4687
4688 if ($use_pinecone) {
4689 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
4690 } else {
4691 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
4692 }
4693 }
4694
4695 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
4696 global $wpdb;
4697 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
4698 $cache_key = 'mxchat_system_prompt_embeddings_' . $bot_id;
4699 $batch_size = 500;
4700
4701 // Initialize similarity analysis storage
4702 $this->last_similarity_analysis = [
4703 'knowledge_base_type' => 'WordPress Database',
4704 'bot_id' => $bot_id,
4705 'top_matches' => [],
4706 'threshold_used' => 0,
4707 'total_checked' => 0
4708 ];
4709
4710 // NEW: Initialize valid URLs array
4711 $valid_urls = [];
4712
4713 // Get bot-specific options for similarity threshold
4714 $bot_options = $this->get_bot_options($bot_id);
4715 $current_options = !empty($bot_options) ? $bot_options : $this->options;
4716
4717 // Retrieve embeddings from cache or database
4718 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
4719 if ($embeddings === false) {
4720 // Cache miss - load embeddings from database WITH CONTENT and ROLE RESTRICTION for testing
4721 $embeddings = [];
4722 $offset = 0;
4723
4724 do {
4725 // Add bot_id filter if not default and if bot_metadata column exists
4726 $bot_filter = '';
4727 if ($bot_id !== 'default') {
4728 // Check if bot_metadata column exists
4729 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
4730 if ($column_exists) {
4731 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
4732 }
4733 }
4734
4735 $query = $wpdb->prepare(
4736 "SELECT id, embedding_vector, article_content, source_url, role_restriction
4737 FROM {$system_prompt_table}
4738 WHERE 1=1 {$bot_filter}
4739 LIMIT %d OFFSET %d",
4740 $batch_size,
4741 $offset
4742 );
4743
4744 $batch = $wpdb->get_results($query);
4745 if (empty($batch)) {
4746 break;
4747 }
4748
4749 $embeddings = array_merge($embeddings, $batch);
4750 $offset += $batch_size;
4751 unset($batch);
4752 } while (true);
4753
4754 if (empty($embeddings)) {
4755 // Store empty array for valid URLs since no content found
4756 $this->current_valid_urls = [];
4757 return '';
4758 }
4759
4760 // Cache embeddings for future use (but note: this now includes content and role restrictions)
4761 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
4762 }
4763
4764 // Get knowledge manager instance for role checking
4765 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4766
4767 // Get base similarity threshold from bot options or default options
4768 $similarity_threshold = isset($current_options['similarity_threshold'])
4769 ? ((int) $current_options['similarity_threshold']) / 100
4770 : 0.35;
4771
4772 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
4773
4774 // Calculate similarities and build results array
4775 $all_similarities = [];
4776 $url_groups = array(); // NEW: Group by source_url for chunk reassembly
4777
4778 foreach ($embeddings as $embedding) {
4779 $database_embedding = $embedding->embedding_vector
4780 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
4781 : null;
4782
4783 if (is_array($database_embedding) && is_array($user_embedding)) {
4784 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
4785
4786 // Check role access
4787 $role_restriction = $embedding->role_restriction ?? 'public';
4788 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
4789
4790 // Store ALL similarities for testing (top 10)
4791 $source_display = '';
4792 $source_url = $embedding->source_url ?? '';
4793 if (!empty($source_url) && $source_url !== '#') {
4794 $source_display = $source_url;
4795 } else {
4796 $content_preview = strip_tags($embedding->article_content ?? '');
4797 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
4798 $source_display = substr(trim($content_preview), 0, 50) . '...';
4799 }
4800
4801 // Parse chunk metadata for display
4802 $article_content_for_parse = $embedding->article_content ?? '';
4803 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
4804 $is_chunk = $parsed_for_display['is_chunked'];
4805 $chunk_meta = $parsed_for_display['metadata'];
4806
4807 $all_similarities[] = [
4808 'document_id' => $embedding->id,
4809 'similarity' => $similarity,
4810 'similarity_percentage' => round($similarity * 100, 2),
4811 'above_threshold' => $similarity >= $similarity_threshold,
4812 'source_display' => $source_display,
4813 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
4814 'used_for_context' => false,
4815 'role_restriction' => $role_restriction,
4816 'has_access' => $has_access,
4817 'filtered_out' => !$has_access,
4818 'is_chunk' => $is_chunk,
4819 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
4820 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
4821 ];
4822
4823 // Only consider results above threshold AND with access for content retrieval
4824 if ($similarity >= $similarity_threshold && $has_access) {
4825 // Parse chunk metadata if present
4826 $article_content = $embedding->article_content ?? '';
4827 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
4828 $is_chunked = $parsed['is_chunked'];
4829 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
4830 $text_content = $parsed['text'];
4831
4832 // Use a unique key for manual entries without a source URL
4833 $group_key = !empty($source_url) ? $source_url : '_manual_' . $embedding->id;
4834
4835 // Group by source URL (or unique key for manual entries)
4836 if (!isset($url_groups[$group_key])) {
4837 $url_groups[$group_key] = array(
4838 'source_url' => $source_url,
4839 'best_score' => 0,
4840 'is_chunked' => $is_chunked,
4841 'chunks' => array(),
4842 'single_text' => '',
4843 'single_id' => null
4844 );
4845 }
4846
4847 // Track best score for this group
4848 if ($similarity > $url_groups[$group_key]['best_score']) {
4849 $url_groups[$group_key]['best_score'] = $similarity;
4850 }
4851
4852 // Store chunk info or single text
4853 if ($is_chunked) {
4854 $url_groups[$group_key]['is_chunked'] = true;
4855 $url_groups[$group_key]['chunks'][] = array(
4856 'id' => $embedding->id,
4857 'score' => $similarity,
4858 'chunk_index' => $chunk_index,
4859 'text' => $text_content
4860 );
4861 } else {
4862 $url_groups[$group_key]['single_text'] = $text_content;
4863 $url_groups[$group_key]['single_id'] = $embedding->id;
4864 }
4865 }
4866 }
4867
4868 unset($database_embedding);
4869 }
4870
4871 // Sort ALL similarities for testing display (highest first)
4872 usort($all_similarities, function ($a, $b) {
4873 return $b['similarity'] <=> $a['similarity'];
4874 });
4875
4876 // Sort URL groups by best score (highest first)
4877 uasort($url_groups, function($a, $b) {
4878 return $b['best_score'] <=> $a['best_score'];
4879 });
4880
4881 // Get RAG sources limit from options (default 6, min 3, max 10)
4882 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 3;
4883 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
4884 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
4885
4886 // Take top N unique URLs based on user setting
4887 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
4888
4889 // Track which document IDs are used for context
4890 $used_document_ids = [];
4891 foreach ($top_urls as $group) {
4892 if ($group['is_chunked']) {
4893 foreach ($group['chunks'] as $chunk) {
4894 $used_document_ids[] = $chunk['id'];
4895 }
4896 } elseif ($group['single_id']) {
4897 $used_document_ids[] = $group['single_id'];
4898 }
4899 }
4900
4901 // Update the all_similarities array to mark which were actually used
4902 foreach ($all_similarities as &$similarity_item) {
4903 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
4904 }
4905
4906 // Store top 10 for testing panel
4907 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
4908 $this->last_similarity_analysis['total_checked'] = count($embeddings);
4909
4910 // Initialize final content
4911 $content = '';
4912 $matches_used = 0;
4913 $total_chunks_used = 0;
4914 $max_total_chunks = isset($options['rag_chunks_limit']) ? intval($options['rag_chunks_limit']) : 15;
4915 if ($max_total_chunks < 8) $max_total_chunks = 8;
4916 if ($max_total_chunks > 20) $max_total_chunks = 20;
4917 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
4918
4919 // Check if citation links are enabled (default to 'on' for backwards compatibility)
4920 // Use fresh options to ensure we get the latest setting value
4921 $fresh_options = get_option('mxchat_options', []);
4922 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
4923
4924 // Build content from top sources
4925 foreach ($top_urls as $group_key => $group) {
4926 $source_url = $group['source_url']; // Use actual source_url, not the group key
4927
4928 // Stop if we've hit the total chunk limit
4929 if ($total_chunks_used >= $max_total_chunks) {
4930 break;
4931 }
4932
4933 $full_text = '';
4934 $chunks_in_this_source = 1; // Default for non-chunked content
4935
4936 if ($group['is_chunked']) {
4937 // Calculate how many chunks we can still use (respect both total and per-source caps)
4938 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
4939
4940 // Fetch chunks for this URL with limit
4941 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
4942
4943 // If fetching all chunks fails, fall back to matched chunks
4944 if (empty($full_text)) {
4945 // Sort matched chunks by index and concatenate
4946 usort($group['chunks'], function($a, $b) {
4947 return $a['chunk_index'] <=> $b['chunk_index'];
4948 });
4949
4950 $chunk_texts = array();
4951 $chunks_in_this_source = 0;
4952 foreach ($group['chunks'] as $chunk) {
4953 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
4954 break;
4955 }
4956 $chunk_texts[] = $chunk['text'];
4957 $chunks_in_this_source++;
4958 }
4959 $full_text = implode("\n\n", $chunk_texts);
4960 }
4961 } else {
4962 $full_text = $group['single_text'];
4963 $chunks_in_this_source = 1;
4964 }
4965
4966 if (!empty($full_text)) {
4967 // Strip URLs from content if citation links are disabled
4968 if (!$citation_links_enabled) {
4969 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
4970 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
4971 }
4972
4973 // Use numbered reference for URL-based entries, plain info label for manual entries
4974 if (!empty($source_url) && $source_url !== '#') {
4975 $matches_used++;
4976 $content .= "## Reference " . $matches_used . " ##\n";
4977 $content .= $full_text . "\n\n";
4978
4979 // Only include citation URLs if citation links are enabled
4980 if ($citation_links_enabled) {
4981 $valid_urls[] = $source_url;
4982 $content .= "URL: " . $source_url . "\n\n";
4983 }
4984 } else {
4985 // Manual entry — no reference number, no citation
4986 $content .= "## Information ##\n";
4987 $content .= $full_text . "\n\n";
4988 }
4989
4990 // Extract any URLs from the text content itself (only if citation links enabled)
4991 if ($citation_links_enabled) {
4992 preg_match_all(
4993 '#\bhttps?://[^\s<>"\']+#i',
4994 $full_text,
4995 $content_urls
4996 );
4997 if (!empty($content_urls[0])) {
4998 $valid_urls = array_merge($valid_urls, $content_urls[0]);
4999 }
5000 }
5001
5002 $total_chunks_used += $chunks_in_this_source;
5003 }
5004 }
5005
5006 // NEW: Store unique valid URLs for validation
5007 $this->current_valid_urls = array_unique($valid_urls);
5008
5009 // Store sources and chunks counts for testing/transcript display
5010 $this->last_similarity_analysis['sources_used'] = $matches_used;
5011 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5012
5013 // Add response guidelines
5014 if (empty($top_urls)) {
5015 $content = "No reference information was found for this query.\n\n";
5016 } else {
5017 // Build response guidelines based on citation links setting
5018 $content .= "\n## Response Guidelines ##\n" .
5019 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5020 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5021 "If you don't have specific information or are uncertain about any details, it's always " .
5022 "better to honestly say you don't know rather than making up or guessing at answers. " .
5023 "When information is incomplete, let them know you are unsure.\n\n";
5024
5025 // Only add hyperlink instructions if citation links are enabled
5026 if ($citation_links_enabled) {
5027 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5028 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5029 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5030 } else {
5031 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5032 "Simply provide helpful answers based on the reference information without citing sources.";
5033 }
5034 }
5035
5036 return trim($content);
5037 }
5038
5039 /**
5040 * Fetch and reassemble chunks for a URL from WordPress database
5041 *
5042 * @param string $source_url The source URL to fetch chunks for
5043 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
5044 * @param int &$chunk_count Reference to store the actual number of chunks returned
5045 * @return string Reassembled content from chunks
5046 */
5047 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
5048 global $wpdb;
5049 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
5050
5051 // Fetch all rows with this source_url
5052 $rows = $wpdb->get_results($wpdb->prepare(
5053 "SELECT article_content FROM {$table}
5054 WHERE source_url = %s
5055 ORDER BY id ASC",
5056 $source_url
5057 ));
5058
5059 if (empty($rows)) {
5060 $chunk_count = 0;
5061 return '';
5062 }
5063
5064 // Parse and sort chunks by index
5065 $chunks = array();
5066 foreach ($rows as $row) {
5067 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
5068
5069 if ($parsed['is_chunked']) {
5070 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
5071 $chunks[$chunk_index] = $parsed['text'];
5072 } else {
5073 // Non-chunked content - just return it
5074 $chunks[] = $parsed['text'];
5075 }
5076 }
5077
5078 // Sort by chunk index
5079 ksort($chunks);
5080
5081 // Apply chunk limit if specified
5082 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5083 $chunks = array_slice($chunks, 0, $max_chunks, true);
5084 }
5085
5086 // Store actual chunk count
5087 $chunk_count = count($chunks);
5088
5089 // Reassemble content
5090 return implode("\n\n", $chunks);
5091 }
5092
5093 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
5094 global $wpdb;
5095
5096 //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
5097 //error_log(" - bot_id: " . $bot_id);
5098 //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
5099 //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
5100
5101 // Use bot-specific config or fall back to default
5102 if ($bot_config === null) {
5103 $bot_config = $this->get_bot_pinecone_config($bot_id);
5104 }
5105
5106 $api_key = $bot_config['api_key'] ?? '';
5107 $host = $bot_config['host'] ?? '';
5108 $namespace = $bot_config['namespace'] ?? '';
5109
5110 //error_log("MXCHAT DEBUG: Pinecone query parameters:");
5111 //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
5112 //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
5113 //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
5114
5115 // Initialize similarity analysis storage
5116 $this->last_similarity_analysis = [
5117 'knowledge_base_type' => 'Pinecone',
5118 'bot_id' => $bot_id,
5119 'namespace' => $namespace,
5120 'top_matches' => [],
5121 'threshold_used' => 0,
5122 'total_checked' => 0
5123 ];
5124
5125 // NEW: Initialize valid URLs array
5126 $valid_urls = [];
5127
5128 if (empty($host) || empty($api_key)) {
5129 //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
5130 //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
5131 //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
5132 // Store empty array for valid URLs since we can't proceed
5133 $this->current_valid_urls = [];
5134 return '';
5135 }
5136
5137 // Get knowledge manager instance for role checking
5138 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5139
5140 // Get the similarity threshold from the bot options or main options
5141 $bot_options = $this->get_bot_options($bot_id);
5142 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
5143
5144 $similarity_threshold = isset($current_options['similarity_threshold'])
5145 ? ((int) $current_options['similarity_threshold']) / 100
5146 : 0.35;
5147
5148 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
5149
5150 // Prepare the query request for Pinecone
5151 $api_endpoint = "https://{$host}/query";
5152
5153 $request_body = array(
5154 'vector' => $user_embedding,
5155 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
5156 'includeMetadata' => true,
5157 'includeValues' => true
5158 );
5159
5160 // Add namespace if specified for this bot
5161 if (!empty($namespace)) {
5162 $request_body['namespace'] = $namespace;
5163 }
5164
5165 //error_log("MXCHAT DEBUG: About to call Pinecone API");
5166 //error_log(" - Endpoint: " . $api_endpoint);
5167 //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
5168
5169 $response = wp_remote_post($api_endpoint, array(
5170 'headers' => array(
5171 'Api-Key' => $api_key,
5172 'accept' => 'application/json',
5173 'content-type' => 'application/json'
5174 ),
5175 'body' => wp_json_encode($request_body),
5176 'timeout' => 30
5177 ));
5178
5179 if (is_wp_error($response)) {
5180 //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
5181 // Store empty array for valid URLs
5182 $this->current_valid_urls = [];
5183 return '';
5184 }
5185
5186 $response_code = wp_remote_retrieve_response_code($response);
5187 //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
5188
5189 if ($response_code !== 200) {
5190 $response_body = wp_remote_retrieve_body($response);
5191 //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
5192 // Store empty array for valid URLs
5193 $this->current_valid_urls = [];
5194 return '';
5195 }
5196
5197 // ADD DETAILED DEBUG SECTION HERE
5198 $response_body = wp_remote_retrieve_body($response);
5199 //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
5200
5201 $results = json_decode($response_body, true);
5202
5203 if (json_last_error() !== JSON_ERROR_NONE) {
5204 //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
5205 //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
5206 // Store empty array for valid URLs
5207 $this->current_valid_urls = [];
5208 return '';
5209 }
5210
5211 //error_log("MXCHAT DEBUG: Pinecone response structure:");
5212 //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
5213 //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
5214
5215 if (empty($results['matches'])) {
5216 //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
5217 //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
5218 // Store empty array for valid URLs
5219 $this->current_valid_urls = [];
5220 return '';
5221 }
5222
5223 //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
5224
5225 // Log first match details for debugging
5226 if (!empty($results['matches'][0])) {
5227 $first_match = $results['matches'][0];
5228 //error_log("MXCHAT DEBUG: First match details:");
5229 //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
5230 //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
5231 if (isset($first_match['metadata'])) {
5232 //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
5233 }
5234 }
5235
5236 // Initialize the final content
5237 $content = '';
5238 $matches_used = 0;
5239 $matches_used_for_context = [];
5240 $total_chunks_used = 0;
5241 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
5242 if ($max_total_chunks < 8) $max_total_chunks = 8;
5243 if ($max_total_chunks > 20) $max_total_chunks = 20;
5244 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
5245
5246 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5247 // Use fresh options to ensure we get the latest setting value
5248 $fresh_options = get_option('mxchat_options', []);
5249 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5250
5251 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
5252 $url_groups = array();
5253
5254 foreach ($results['matches'] as $index => $match) {
5255 // Skip if similarity is below threshold
5256 if ($match['score'] < $similarity_threshold) {
5257 continue;
5258 }
5259
5260 $metadata = $match['metadata'] ?? array();
5261 $source_url = $metadata['source_url'] ?? '';
5262 $match_id = $match['id'] ?? '';
5263
5264 // LAZY ROLE CHECK: Only check role for content we're actually considering
5265 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
5266 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5267
5268 // Skip if user doesn't have access
5269 if (!$has_access) {
5270 continue;
5271 }
5272
5273 // Use a unique key for manual entries without a source URL
5274 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
5275
5276 // Group by source URL (or unique key for manual entries)
5277 if (!isset($url_groups[$group_key])) {
5278 $url_groups[$group_key] = array(
5279 'source_url' => $source_url,
5280 'best_score' => 0,
5281 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
5282 'chunks' => array(),
5283 'single_text' => ''
5284 );
5285 }
5286
5287 // Track best score for this group
5288 if ($match['score'] > $url_groups[$group_key]['best_score']) {
5289 $url_groups[$group_key]['best_score'] = $match['score'];
5290 }
5291
5292 // Store chunk info or single text
5293 if ($url_groups[$group_key]['is_chunked']) {
5294 $url_groups[$group_key]['chunks'][] = array(
5295 'id' => $match_id,
5296 'score' => $match['score'],
5297 'chunk_index' => $metadata['chunk_index'] ?? 0,
5298 'text' => $metadata['text'] ?? ''
5299 );
5300 } else {
5301 // Non-chunked content - just store the text
5302 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
5303 $url_groups[$group_key]['single_id'] = $match_id;
5304 }
5305 }
5306
5307 // Sort URL groups by best score (highest first)
5308 uasort($url_groups, function($a, $b) {
5309 return $b['best_score'] <=> $a['best_score'];
5310 });
5311
5312 // Get RAG sources limit from options (default 6, min 3, max 10)
5313 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
5314 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
5315 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
5316
5317 // Take top N unique URLs based on user setting
5318 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
5319
5320 // Track which match IDs are actually used for context
5321 foreach ($top_urls as $group) {
5322 if ($group['is_chunked']) {
5323 foreach ($group['chunks'] as $chunk) {
5324 $matches_used_for_context[] = $chunk['id'];
5325 }
5326 } elseif (!empty($group['single_id'])) {
5327 $matches_used_for_context[] = $group['single_id'];
5328 }
5329 }
5330
5331 // Build content from top sources
5332 foreach ($top_urls as $group_key => $group) {
5333 $source_url = $group['source_url']; // Use actual source_url, not the group key
5334
5335 // Stop if we've hit the total chunk limit
5336 if ($total_chunks_used >= $max_total_chunks) {
5337 break;
5338 }
5339
5340 $full_text = '';
5341 $chunks_in_this_source = 1; // Default for non-chunked content
5342
5343 if ($group['is_chunked']) {
5344 // Calculate how many chunks we can still use (respect both total and per-source caps)
5345 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
5346
5347 // Fetch chunks for this URL with limit
5348 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
5349
5350 // If fetching all chunks fails, fall back to matched chunks
5351 if (empty($full_text)) {
5352 // Sort matched chunks by index and concatenate
5353 usort($group['chunks'], function($a, $b) {
5354 return $a['chunk_index'] <=> $b['chunk_index'];
5355 });
5356
5357 $chunk_texts = array();
5358 $chunks_in_this_source = 0;
5359 foreach ($group['chunks'] as $chunk) {
5360 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5361 break;
5362 }
5363 $chunk_texts[] = $chunk['text'];
5364 $chunks_in_this_source++;
5365 }
5366 $full_text = implode("\n\n", $chunk_texts);
5367 }
5368 } else {
5369 $full_text = $group['single_text'];
5370 $chunks_in_this_source = 1;
5371 }
5372
5373 if (!empty($full_text)) {
5374 // Strip URLs from content if citation links are disabled
5375 if (!$citation_links_enabled) {
5376 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5377 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5378 }
5379
5380 // Use numbered reference for URL-based entries, plain info label for manual entries
5381 if (!empty($source_url) && $source_url !== '#') {
5382 $matches_used++;
5383 $content .= "## Reference " . $matches_used . " ##\n";
5384 $content .= $full_text . "\n\n";
5385
5386 // Only include citation URLs if citation links are enabled
5387 if ($citation_links_enabled) {
5388 $valid_urls[] = $source_url;
5389 $content .= "URL: " . $source_url . "\n\n";
5390 }
5391 } else {
5392 // Manual entry — no reference number, no citation
5393 $content .= "## Information ##\n";
5394 $content .= $full_text . "\n\n";
5395 }
5396
5397 // Extract any URLs from the text content itself (only if citation links enabled)
5398 if ($citation_links_enabled) {
5399 preg_match_all(
5400 '#\bhttps?://[^\s<>"\']+#i',
5401 $full_text,
5402 $content_urls
5403 );
5404 if (!empty($content_urls[0])) {
5405 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5406 }
5407 }
5408
5409 $total_chunks_used += $chunks_in_this_source;
5410 }
5411 }
5412
5413 // Process ALL matches for testing data (top 10) - with role checking for testing display
5414 $all_matches = [];
5415 foreach ($results['matches'] as $index => $match) {
5416 if ($index >= 10) break; // Limit to top 10 for testing
5417
5418 $match_id = $match['id'] ?? '';
5419
5420 // Check role access for testing display (use cache if available)
5421 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
5422 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5423
5424 $source_display = '';
5425 if (!empty($match['metadata']['source_url'])) {
5426 $source_display = $match['metadata']['source_url'];
5427 } else {
5428 $content_preview = strip_tags($match['metadata']['text'] ?? '');
5429 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
5430 $source_display = substr(trim($content_preview), 0, 50) . '...';
5431 }
5432
5433 $match_id_for_display = $match['id'] ?? $index;
5434
5435 // Check for chunk metadata in Pinecone
5436 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
5437 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
5438 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
5439
5440 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
5441 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
5442 $is_chunk = true;
5443 }
5444
5445 $all_matches[] = [
5446 'document_id' => $match_id_for_display,
5447 'similarity' => $match['score'],
5448 'similarity_percentage' => round($match['score'] * 100, 2),
5449 'above_threshold' => $match['score'] >= $similarity_threshold,
5450 'source_display' => $source_display,
5451 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
5452 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
5453 'role_restriction' => $role_restriction,
5454 'has_access' => $has_access,
5455 'filtered_out' => !$has_access,
5456 'is_chunk' => $is_chunk,
5457 'chunk_index' => $chunk_index,
5458 'total_chunks' => $total_chunks
5459 ];
5460 }
5461
5462 // Store for testing panel
5463 $this->last_similarity_analysis['top_matches'] = $all_matches;
5464 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
5465 $this->last_similarity_analysis['sources_used'] = $matches_used;
5466 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5467
5468 // NEW: Store unique valid URLs for validation
5469 $this->current_valid_urls = array_unique($valid_urls);
5470
5471 // Add response guidelines
5472 if ($matches_used === 0) {
5473 $content = "No reference information was found for this query.\n\n";
5474 } else {
5475 // Build response guidelines based on citation links setting
5476 $content .= "\n## Response Guidelines ##\n" .
5477 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5478 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5479 "If you don't have specific information or are uncertain about any details, it's always " .
5480 "better to honestly say you don't know rather than making up or guessing at answers. " .
5481 "When information is incomplete, let them know you are unsure.\n\n";
5482
5483 // Only add hyperlink instructions if citation links are enabled
5484 if ($citation_links_enabled) {
5485 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5486 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5487 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5488 } else {
5489 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5490 "Simply provide helpful answers based on the reference information without citing sources.";
5491 }
5492 }
5493
5494 return trim($content);
5495 }
5496
5497 /**
5498 * Get role restriction for a single vector (with caching)
5499 */
5500 private function get_single_vector_role($vector_id, $metadata = array()) {
5501 global $wpdb;
5502
5503 if (empty($vector_id)) {
5504 return 'public';
5505 }
5506
5507 // Check cache first
5508 $cache_key = 'mxchat_vector_role_' . $vector_id;
5509 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
5510
5511 if ($cached_role !== false) {
5512 return $cached_role;
5513 }
5514
5515 $role_restriction = 'public';
5516
5517 // First try Pinecone metadata
5518 if (!empty($metadata['role_restriction'])) {
5519 $role_restriction = $metadata['role_restriction'];
5520 } else {
5521 // Check WordPress table for user-modified roles
5522 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5523 $stored_role = $wpdb->get_var($wpdb->prepare(
5524 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
5525 $vector_id
5526 ));
5527
5528 if ($stored_role) {
5529 $role_restriction = $stored_role;
5530 }
5531 }
5532
5533 // Cache individual role for 1 hour
5534 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
5535
5536 return $role_restriction;
5537 }
5538
5539 /**
5540 * Fetch and reassemble all chunks for a URL from Pinecone
5541 *
5542 * @param string $source_url The source URL to fetch chunks for
5543 * @param array $bot_config Bot-specific Pinecone configuration
5544 * @return string Reassembled content from all chunks
5545 */
5546 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
5547 $api_key = $bot_config['api_key'] ?? '';
5548 $host = $bot_config['host'] ?? '';
5549 $namespace = $bot_config['namespace'] ?? '';
5550
5551 if (empty($host) || empty($api_key)) {
5552 $chunk_count = 0;
5553 return '';
5554 }
5555
5556 $base_hash = md5($source_url);
5557
5558 // Use Pinecone list API to find all chunk vectors with this prefix
5559 $list_url = "https://{$host}/vectors/list";
5560
5561 // Limit to max_chunks if specified, otherwise fetch up to 100
5562 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
5563
5564 $list_body = array(
5565 'prefix' => $base_hash . '_chunk_',
5566 'limit' => $fetch_limit
5567 );
5568
5569 if (!empty($namespace)) {
5570 $list_body['namespace'] = $namespace;
5571 }
5572
5573 $list_response = wp_remote_post($list_url, array(
5574 'headers' => array(
5575 'Api-Key' => $api_key,
5576 'accept' => 'application/json',
5577 'content-type' => 'application/json'
5578 ),
5579 'body' => wp_json_encode($list_body),
5580 'timeout' => 30
5581 ));
5582
5583 if (is_wp_error($list_response)) {
5584 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
5585 return '';
5586 }
5587
5588 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
5589
5590 if (empty($list_data['vectors'])) {
5591 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
5592 return '';
5593 }
5594
5595 // Extract vector IDs
5596 $vector_ids = array();
5597 foreach ($list_data['vectors'] as $vector) {
5598 if (isset($vector['id'])) {
5599 $vector_ids[] = $vector['id'];
5600 }
5601 }
5602
5603 if (empty($vector_ids)) {
5604 return '';
5605 }
5606
5607 // Fetch all chunk content
5608 $fetch_url = "https://{$host}/vectors/fetch";
5609
5610 $fetch_body = array(
5611 'ids' => $vector_ids
5612 );
5613
5614 if (!empty($namespace)) {
5615 $fetch_body['namespace'] = $namespace;
5616 }
5617
5618 $fetch_response = wp_remote_post($fetch_url, array(
5619 'headers' => array(
5620 'Api-Key' => $api_key,
5621 'accept' => 'application/json',
5622 'content-type' => 'application/json'
5623 ),
5624 'body' => wp_json_encode($fetch_body),
5625 'timeout' => 30
5626 ));
5627
5628 if (is_wp_error($fetch_response)) {
5629 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
5630 return '';
5631 }
5632
5633 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
5634
5635 if (empty($fetch_data['vectors'])) {
5636 return '';
5637 }
5638
5639 // Sort chunks by index and reassemble
5640 $chunks = array();
5641 foreach ($fetch_data['vectors'] as $id => $vector) {
5642 $metadata = $vector['metadata'] ?? array();
5643 $chunk_index = $metadata['chunk_index'] ?? 0;
5644 $text = $metadata['text'] ?? '';
5645
5646 // Store chunk with its index
5647 $chunks[$chunk_index] = $text;
5648 }
5649
5650 // Sort by chunk index
5651 ksort($chunks);
5652
5653 // Apply chunk limit if specified
5654 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5655 $chunks = array_slice($chunks, 0, $max_chunks, true);
5656 }
5657
5658 // Store actual chunk count
5659 $chunk_count = count($chunks);
5660
5661 // Reassemble content
5662 return implode("\n\n", $chunks);
5663 }
5664
5665 /**
5666 * Search for relevant content using OpenAI Vector Store (File Search)
5667 *
5668 * @param string $user_query The user's query text
5669 * @param string $bot_id The bot ID
5670 * @param array $vectorstore_config Vector Store configuration
5671 * @return string Formatted context string with references
5672 */
5673 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
5674 //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
5675 //error_log(" - bot_id: " . $bot_id);
5676 //error_log(" - user_query length: " . strlen($user_query));
5677
5678 // Get OpenAI API key
5679 $mxchat_options = get_option('mxchat_options', array());
5680 $api_key = $mxchat_options['api_key'] ?? '';
5681
5682 // Reset vectorstore error tracking
5683 $this->last_vectorstore_error = null;
5684
5685 if (empty($api_key)) {
5686 //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
5687 $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
5688 $this->current_valid_urls = [];
5689 return '';
5690 }
5691
5692 // Get Vector Store configuration
5693 if (empty($vectorstore_config)) {
5694 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
5695 }
5696
5697 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
5698 $max_results = $vectorstore_config['max_results'] ?? 5;
5699
5700 if (empty($vectorstore_ids_string)) {
5701 //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
5702 $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
5703 $this->current_valid_urls = [];
5704 return '';
5705 }
5706
5707 // Parse Vector Store IDs
5708 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
5709 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
5710
5711 //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5712 //error_log("MXCHAT DEBUG: Max results: " . $max_results);
5713
5714 // Initialize similarity analysis storage
5715 $this->last_similarity_analysis = [
5716 'knowledge_base_type' => 'OpenAI Vector Store',
5717 'bot_id' => $bot_id,
5718 'vectorstore_ids' => $vectorstore_ids,
5719 'top_matches' => [],
5720 'threshold_used' => 0,
5721 'total_checked' => 0
5722 ];
5723
5724 $valid_urls = [];
5725
5726 // Get the selected model
5727 $bot_options = $this->get_bot_options($bot_id);
5728 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
5729 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
5730
5731 // Verify it's an OpenAI model
5732 if (!$this->is_openai_chat_model($selected_model)) {
5733 //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
5734 $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
5735 $this->current_valid_urls = [];
5736 return '';
5737 }
5738
5739 // Use OpenAI Responses API with file_search tool
5740 $request_body = array(
5741 'model' => $selected_model,
5742 'input' => $user_query,
5743 'tools' => array(
5744 array(
5745 'type' => 'file_search',
5746 'vector_store_ids' => $vectorstore_ids,
5747 'max_num_results' => intval($max_results)
5748 )
5749 ),
5750 'include' => array('output[*].file_search_call.search_results')
5751 );
5752
5753 //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
5754 //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
5755 //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
5756 //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5757 //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
5758 //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
5759
5760 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
5761 'headers' => array(
5762 'Authorization' => 'Bearer ' . $api_key,
5763 'Content-Type' => 'application/json'
5764 ),
5765 'body' => wp_json_encode($request_body),
5766 'timeout' => 60
5767 ));
5768
5769 if (is_wp_error($response)) {
5770 //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
5771 $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
5772 $this->current_valid_urls = [];
5773 return '';
5774 }
5775
5776 $response_code = wp_remote_retrieve_response_code($response);
5777 //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
5778
5779 $response_body = wp_remote_retrieve_body($response);
5780 //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
5781
5782 if ($response_code !== 200) {
5783 //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
5784 $api_error_detail = '';
5785 $decoded_error = json_decode($response_body, true);
5786 if (isset($decoded_error['error']['message'])) {
5787 $api_error_detail = $decoded_error['error']['message'];
5788 }
5789 $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
5790 $this->current_valid_urls = [];
5791 return '';
5792 }
5793 $result = json_decode($response_body, true);
5794
5795 if (json_last_error() !== JSON_ERROR_NONE) {
5796 //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
5797 $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
5798 $this->current_valid_urls = [];
5799 return '';
5800 }
5801
5802 // Debug: Log the structure of the result
5803 //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
5804 if (isset($result['output'])) {
5805 //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
5806 foreach ($result['output'] as $idx => $out) {
5807 //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
5808 //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
5809 }
5810 } else {
5811 //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
5812 }
5813
5814 // Extract file search results from the response
5815 $content = '';
5816 $matches_used = 0;
5817 $all_matches = [];
5818
5819 // The Responses API returns output array with tool results
5820 if (isset($result['output']) && is_array($result['output'])) {
5821 foreach ($result['output'] as $output_item) {
5822 // Look for file_search_call results
5823 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
5824 //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
5825 //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
5826
5827 // Check for search_results in the output item directly
5828 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
5829 //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
5830
5831 if (empty($search_results)) {
5832 //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
5833 //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
5834 }
5835
5836 foreach ($search_results as $index => $search_result) {
5837 $filename = $search_result['filename'] ?? '';
5838 $score = $search_result['score'] ?? 0;
5839 $text_content = '';
5840
5841 // Extract text content from the result
5842 // The text can be directly on the result OR nested under content array
5843 if (isset($search_result['text']) && !empty($search_result['text'])) {
5844 // Direct text field (OpenAI's actual format)
5845 $text_content = $search_result['text'];
5846 //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
5847 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
5848 // Nested content array format
5849 foreach ($search_result['content'] as $content_item) {
5850 if (isset($content_item['text'])) {
5851 $text_content .= $content_item['text'] . "\n";
5852 }
5853 }
5854 //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
5855 } else {
5856 //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
5857 }
5858
5859 if (!empty($text_content)) {
5860 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5861 $content .= trim($text_content) . "\n\n";
5862
5863 if (!empty($filename)) {
5864 $content .= "Source: " . $filename . "\n\n";
5865 }
5866
5867 // Extract URLs from content
5868 preg_match_all(
5869 '#\bhttps?://[^\s<>"\']+#i',
5870 $text_content,
5871 $content_urls
5872 );
5873 if (!empty($content_urls[0])) {
5874 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5875 }
5876
5877 $matches_used++;
5878 }
5879
5880 // Store for similarity analysis
5881 $all_matches[] = [
5882 'document_id' => $filename ?: ('result_' . $index),
5883 'similarity' => $score,
5884 'similarity_percentage' => round($score * 100, 2),
5885 'above_threshold' => true,
5886 'source_display' => $filename,
5887 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5888 'used_for_context' => true,
5889 'role_restriction' => 'public',
5890 'has_access' => true,
5891 'filtered_out' => false
5892 ];
5893 }
5894 }
5895
5896 // Also check for message content with annotations (citations)
5897 if (isset($output_item['type']) && $output_item['type'] === 'message') {
5898 if (isset($output_item['content']) && is_array($output_item['content'])) {
5899 foreach ($output_item['content'] as $content_block) {
5900 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
5901 foreach ($content_block['annotations'] as $annotation) {
5902 if (isset($annotation['filename'])) {
5903 $filename = $annotation['filename'];
5904 $score = $annotation['score'] ?? 0;
5905 $text_content = '';
5906
5907 if (isset($annotation['content']) && is_array($annotation['content'])) {
5908 foreach ($annotation['content'] as $ann_content) {
5909 if (isset($ann_content['text'])) {
5910 $text_content .= $ann_content['text'] . "\n";
5911 }
5912 }
5913 }
5914
5915 if (!empty($text_content) && $matches_used < $max_results) {
5916 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5917 $content .= trim($text_content) . "\n\n";
5918 $content .= "Source: " . $filename . "\n\n";
5919
5920 preg_match_all(
5921 '#\bhttps?://[^\s<>"\']+#i',
5922 $text_content,
5923 $content_urls
5924 );
5925 if (!empty($content_urls[0])) {
5926 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5927 }
5928
5929 $matches_used++;
5930
5931 $all_matches[] = [
5932 'document_id' => $filename,
5933 'similarity' => $score,
5934 'similarity_percentage' => round($score * 100, 2),
5935 'above_threshold' => true,
5936 'source_display' => $filename,
5937 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5938 'used_for_context' => true,
5939 'role_restriction' => 'public',
5940 'has_access' => true,
5941 'filtered_out' => false
5942 ];
5943 }
5944 }
5945 }
5946 }
5947 }
5948 }
5949 }
5950 }
5951 }
5952
5953 // Store for testing panel
5954 $this->last_similarity_analysis['top_matches'] = $all_matches;
5955 $this->last_similarity_analysis['total_checked'] = count($all_matches);
5956
5957 // Store unique valid URLs for validation
5958 $this->current_valid_urls = array_unique($valid_urls);
5959
5960 //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
5961 //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
5962 //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
5963 //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
5964 if ($matches_used > 0) {
5965 //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
5966 }
5967
5968 // Check if citation links are enabled
5969 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
5970
5971 // Add response guidelines
5972 if ($matches_used === 0) {
5973 //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
5974 $content = "No reference information was found for this query.\n\n";
5975 } else {
5976 // Build response guidelines based on citation links setting
5977 $content .= "\n## Response Guidelines ##\n" .
5978 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5979 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5980 "If you don't have specific information or are uncertain about any details, it's always " .
5981 "better to honestly say you don't know rather than making up or guessing at answers. " .
5982 "When information is incomplete, let them know you are unsure.\n\n";
5983
5984 // Only add hyperlink instructions if citation links are enabled
5985 if ($citation_links_enabled) {
5986 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5987 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
5988 } else {
5989 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5990 "Simply provide helpful answers based on the reference information without citing sources.";
5991 }
5992 }
5993
5994 //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
5995
5996 return trim($content);
5997 }
5998
5999 /**
6000 * Check if the given model is an OpenAI chat model
6001 *
6002 * @param string $model The model ID
6003 * @return bool True if it's an OpenAI model
6004 */
6005 private function is_openai_chat_model($model) {
6006 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
6007 foreach ($openai_prefixes as $prefix) {
6008 if (strpos($model, $prefix) === 0) {
6009 return true;
6010 }
6011 }
6012 return false;
6013 }
6014
6015 /**
6016 * Get bot-specific Vector Store configuration
6017 *
6018 * @param string $bot_id The bot ID
6019 * @return array Configuration array
6020 */
6021 private function get_bot_vectorstore_config($bot_id = 'default') {
6022 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
6023
6024 // Default global settings
6025 $default_config = array(
6026 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
6027 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
6028 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
6029 );
6030
6031 // Allow multi-bot plugin to override with bot-specific settings
6032 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
6033
6034 // Preserve max_results from global settings if not set in bot config
6035 if (!isset($bot_config['max_results'])) {
6036 $bot_config['max_results'] = $default_config['max_results'];
6037 }
6038
6039 return $bot_config;
6040 }
6041
6042 private function mxchat_find_relevant_products($user_embedding) {
6043 //error_log('MXChat Vector Search: Starting product search...');
6044
6045 // Retrieve the add-on settings from the database
6046 $addon_options = get_option('mxchat_pinecone_addon_options', array());
6047
6048 // Determine whether Pinecone is enabled
6049 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
6050
6051 //error_log('Pinecone enabled flag: ' . $use_pinecone);
6052
6053 if ($use_pinecone === 1) {
6054 //error_log('MXChat Vector Search: Using Pinecone database for products');
6055 return $this->find_relevant_products_pinecone($user_embedding);
6056 } else {
6057 //error_log('MXChat Vector Search: Using WordPress database for products');
6058 return $this->find_relevant_products_wordpress($user_embedding);
6059 }
6060 }
6061 private function find_relevant_products_wordpress($user_embedding) {
6062 global $wpdb;
6063 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6064 $cache_key = 'mxchat_system_prompt_embeddings';
6065 $batch_size = 500;
6066
6067 // Original WordPress database search logic
6068 // [Previous implementation remains the same]
6069 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
6070 if ($embeddings === false) {
6071 $embeddings = [];
6072 $offset = 0;
6073
6074 do {
6075 $query = $wpdb->prepare(
6076 "SELECT id, embedding_vector
6077 FROM {$system_prompt_table}
6078 LIMIT %d OFFSET %d",
6079 $batch_size,
6080 $offset
6081 );
6082
6083 $batch = $wpdb->get_results($query);
6084 if (empty($batch)) {
6085 break;
6086 }
6087
6088 $embeddings = array_merge($embeddings, $batch);
6089 $offset += $batch_size;
6090
6091 unset($batch);
6092
6093 } while (true);
6094
6095 if (empty($embeddings)) {
6096 return '';
6097 }
6098 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
6099 }
6100
6101 $relevant_results = [];
6102 foreach ($embeddings as $embedding) {
6103 $database_embedding = $embedding->embedding_vector
6104 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
6105 : null;
6106 if (is_array($database_embedding) && is_array($user_embedding)) {
6107 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6108 $relevant_results[] = [
6109 'id' => $embedding->id,
6110 'similarity' => $similarity
6111 ];
6112 }
6113 unset($database_embedding);
6114 }
6115
6116 // Use fixed threshold for products
6117 $similarity_threshold = 0.85;
6118
6119 $relevant_results = array_filter($relevant_results, function ($result) use ($similarity_threshold) {
6120 return $result['similarity'] >= $similarity_threshold;
6121 });
6122 usort($relevant_results, function ($a, $b) {
6123 return $b['similarity'] <=> $a['similarity'];
6124 });
6125
6126 $top_results = array_slice($relevant_results, 0, 3);
6127 $content = '';
6128
6129 foreach ($top_results as $result) {
6130 $chunk_content = $this->fetch_content_with_product_links($result['id']);
6131 $content .= $chunk_content . "\n\n";
6132 }
6133
6134 return trim($content);
6135 }
6136
6137
6138 private function find_relevant_products_pinecone($user_embedding) {
6139 //error_log('Starting Pinecone product search...');
6140
6141 $options = get_option('mxchat_pinecone_addon_options', array());
6142 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
6143 $host = $options['mxchat_pinecone_host'] ?? '';
6144
6145 if (empty($host) || empty($api_key)) {
6146 //error_log('Pinecone credentials not properly configured for product search');
6147 return '';
6148 }
6149
6150 $similarity_threshold = 0.85;
6151 $api_endpoint = "https://{$host}/query";
6152
6153 $request_body = array(
6154 'vector' => $user_embedding,
6155 'topK' => 5,
6156 'includeMetadata' => true,
6157 'includeValues' => true,
6158 'filter' => array(
6159 'type' => 'product'
6160 )
6161 );
6162
6163 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
6164
6165 $response = wp_remote_post($api_endpoint, array(
6166 'headers' => array(
6167 'Api-Key' => $api_key,
6168 'accept' => 'application/json',
6169 'content-type' => 'application/json'
6170 ),
6171 'body' => wp_json_encode($request_body),
6172 'timeout' => 30
6173 ));
6174
6175 if (is_wp_error($response)) {
6176 //error_log('Pinecone product query error: ' . $response->get_error_message());
6177 return '';
6178 }
6179
6180 $response_code = wp_remote_retrieve_response_code($response);
6181 //error_log('Pinecone response code: ' . $response_code);
6182
6183 if ($response_code !== 200) {
6184 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
6185 return '';
6186 }
6187
6188 $results = json_decode(wp_remote_retrieve_body($response), true);
6189 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
6190
6191 if (empty($results['matches'])) {
6192 //error_log('No matches found in Pinecone response');
6193 return '';
6194 }
6195
6196 $content = '';
6197 foreach ($results['matches'] as $match) {
6198 if ($match['score'] < $similarity_threshold) {
6199 //error_log("Match below threshold: " . $match['score']);
6200 continue;
6201 }
6202
6203 if (!empty($match['metadata']['text'])) {
6204 $content .= $match['metadata']['text'];
6205 if (!empty($match['metadata']['source_url'])) {
6206 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
6207 }
6208 $content .= "\n\n";
6209 }
6210 }
6211
6212 return trim($content);
6213 }
6214
6215
6216 private function fetch_content_with_product_links($most_relevant_id) {
6217 global $wpdb;
6218 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6219
6220 // Fetch the article content and associated product URL
6221 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
6222 $result = $wpdb->get_row($query);
6223
6224 if ($result) {
6225 // Append the product link to the content if available
6226 $content = $result->article_content;
6227 if (!empty($result->source_url)) {
6228 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
6229 }
6230 return $content;
6231 }
6232
6233 return null;
6234 }
6235
6236 /**
6237 * Get system instructions for a specific bot or default
6238 * Checks for multi-bot add-on and uses bot-specific instructions if available
6239 * Automatically strips URLs if citation links are disabled
6240 * Replaces {visitor_name} placeholder with actual visitor name if available
6241 *
6242 * @param string $bot_id The bot ID to get instructions for
6243 * @param string $session_id Optional session ID to lookup visitor name
6244 */
6245 private function get_system_instructions($bot_id = 'default', $session_id = '') {
6246 $instructions = '';
6247
6248 // Check if multi-bot add-on is active
6249 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
6250 // Get bot-specific options from multi-bot add-on
6251 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
6252
6253 // If bot has custom system instructions, use those
6254 if (!empty($bot_options['system_prompt_instructions'])) {
6255 $instructions = $bot_options['system_prompt_instructions'];
6256 }
6257 }
6258
6259 // Fall back to default system instructions
6260 if (empty($instructions)) {
6261 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
6262 }
6263
6264 // Check if citation links are disabled - if so, strip URLs from instructions
6265 $fresh_options = get_option('mxchat_options', []);
6266 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6267
6268 if (!$citation_links_enabled && !empty($instructions)) {
6269 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
6270 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
6271 }
6272
6273 // Replace {visitor_name} placeholder with actual visitor name if available
6274 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
6275 $name_option_key = "mxchat_name_{$session_id}";
6276 $visitor_name = get_option($name_option_key, '');
6277
6278 if (!empty($visitor_name)) {
6279 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
6280 } else {
6281 // Remove placeholder if no name is available
6282 $instructions = str_ireplace('{visitor_name}', '', $instructions);
6283 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
6284 }
6285 }
6286
6287 // Allow developers to filter system instructions and process shortcodes
6288 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
6289 $instructions = do_shortcode($instructions);
6290
6291 return $instructions;
6292 }
6293 /**
6294 * Get the current bot ID from session or request context
6295 */
6296 private function get_current_bot_id($session_id = '') {
6297 // First, check if bot_id is passed in the current request
6298 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
6299 return sanitize_key($_POST['bot_id']);
6300 }
6301
6302 // If not in POST, try to get it from session data
6303 if (!empty($session_id)) {
6304 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
6305 if (!empty($bot_id)) {
6306 return $bot_id;
6307 }
6308 }
6309
6310 // Fall back to default
6311 return 'default';
6312 }
6313 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') {
6314 try {
6315 if (!$relevant_content) {
6316 $error_response = [
6317 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
6318 'error_code' => 'no_relevant_content'
6319 ];
6320
6321 if ($testing_data !== null) {
6322 $error_response['testing_data'] = $testing_data;
6323 }
6324
6325 return $error_response;
6326 }
6327
6328 if (!is_array($conversation_history)) {
6329 $conversation_history = array();
6330 }
6331
6332 // Check if this is an OpenRouter model
6333 if ($selected_model === 'openrouter') {
6334 // Get the actual OpenRouter model from options
6335 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
6336
6337 if (empty($openrouter_selected_model)) {
6338 $error_response = [
6339 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
6340 'error_code' => 'no_openrouter_model_selected'
6341 ];
6342 if ($testing_data !== null) {
6343 $error_response['testing_data'] = $testing_data;
6344 }
6345 return $error_response;
6346 }
6347
6348 if (empty($openrouter_api_key)) {
6349 $error_response = [
6350 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
6351 'error_code' => 'missing_openrouter_api_key'
6352 ];
6353 if ($testing_data !== null) {
6354 $error_response['testing_data'] = $testing_data;
6355 }
6356 return $error_response;
6357 }
6358
6359 if ($streaming) {
6360 return $this->mxchat_generate_response_openrouter_stream(
6361 $openrouter_selected_model,
6362 $openrouter_api_key,
6363 $conversation_history,
6364 $relevant_content,
6365 $session_id,
6366 $testing_data
6367 );
6368 } else {
6369 $response = $this->mxchat_generate_response_openrouter(
6370 $openrouter_selected_model,
6371 $openrouter_api_key,
6372 $conversation_history,
6373 $relevant_content
6374 );
6375 }
6376
6377 if (is_array($response) && isset($response['error'])) {
6378 if ($testing_data !== null) {
6379 $response['testing_data'] = $testing_data;
6380 }
6381 return $response;
6382 }
6383
6384 return $response;
6385 }
6386
6387 // Extract model prefix to determine the provider
6388 $model_parts = explode('-', $selected_model);
6389 $provider = strtolower($model_parts[0]);
6390
6391 // Handle model selection based on provider prefix
6392 switch ($provider) {
6393 case 'gemini':
6394 if (empty($gemini_api_key)) {
6395 $error_response = [
6396 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
6397 'error_code' => 'missing_gemini_api_key'
6398 ];
6399 if ($testing_data !== null) {
6400 $error_response['testing_data'] = $testing_data;
6401 }
6402 return $error_response;
6403 }
6404 $response = $this->mxchat_generate_response_gemini(
6405 $selected_model,
6406 $gemini_api_key,
6407 $conversation_history,
6408 $relevant_content
6409 );
6410 break;
6411
6412 case 'claude':
6413 if (empty($claude_api_key)) {
6414 $error_response = [
6415 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
6416 'error_code' => 'missing_claude_api_key'
6417 ];
6418 if ($testing_data !== null) {
6419 $error_response['testing_data'] = $testing_data;
6420 }
6421 return $error_response;
6422 }
6423 if ($streaming) {
6424 return $this->mxchat_generate_response_claude_stream(
6425 $selected_model,
6426 $claude_api_key,
6427 $conversation_history,
6428 $relevant_content,
6429 $session_id,
6430 $testing_data
6431 );
6432 } else {
6433 $response = $this->mxchat_generate_response_claude(
6434 $selected_model,
6435 $claude_api_key,
6436 $conversation_history,
6437 $relevant_content
6438 );
6439 }
6440 break;
6441
6442 case 'grok':
6443 if (empty($xai_api_key)) {
6444 $error_response = [
6445 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
6446 'error_code' => 'missing_xai_api_key'
6447 ];
6448 if ($testing_data !== null) {
6449 $error_response['testing_data'] = $testing_data;
6450 }
6451 return $error_response;
6452 }
6453 if ($streaming) {
6454 return $this->mxchat_generate_response_xai_stream(
6455 $selected_model,
6456 $xai_api_key,
6457 $conversation_history,
6458 $relevant_content,
6459 $session_id,
6460 $testing_data
6461 );
6462 } else {
6463 $response = $this->mxchat_generate_response_xai(
6464 $selected_model,
6465 $xai_api_key,
6466 $conversation_history,
6467 $relevant_content
6468 );
6469 }
6470 break;
6471
6472 case 'deepseek':
6473 if (empty($deepseek_api_key)) {
6474 $error_response = [
6475 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
6476 'error_code' => 'missing_deepseek_api_key'
6477 ];
6478 if ($testing_data !== null) {
6479 $error_response['testing_data'] = $testing_data;
6480 }
6481 return $error_response;
6482 }
6483 if ($streaming) {
6484 return $this->mxchat_generate_response_deepseek_stream(
6485 $selected_model,
6486 $deepseek_api_key,
6487 $conversation_history,
6488 $relevant_content,
6489 $session_id,
6490 $testing_data
6491 );
6492 } else {
6493 $response = $this->mxchat_generate_response_deepseek(
6494 $selected_model,
6495 $deepseek_api_key,
6496 $conversation_history,
6497 $relevant_content
6498 );
6499 }
6500 break;
6501
6502 case 'gpt':
6503 case 'o1':
6504 if (empty($api_key)) {
6505 $error_response = [
6506 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6507 'error_code' => 'missing_openai_api_key'
6508 ];
6509 if ($testing_data !== null) {
6510 $error_response['testing_data'] = $testing_data;
6511 }
6512 return $error_response;
6513 }
6514
6515 // Check if web search is enabled for this OpenAI model
6516 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6517 // Models that don't support web search
6518 $unsupported_web_search_models = array('gpt-4.1-nano');
6519 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6520
6521 if ($web_search_enabled && $model_supports_web_search) {
6522 // Use Responses API (required for some models, or when web search is enabled)
6523 return $this->mxchat_generate_response_openai_web_search(
6524 $selected_model,
6525 $api_key,
6526 $conversation_history,
6527 $relevant_content,
6528 $session_id,
6529 $testing_data,
6530 $streaming
6531 );
6532 } elseif ($streaming) {
6533 return $this->mxchat_generate_response_openai_stream(
6534 $selected_model,
6535 $api_key,
6536 $conversation_history,
6537 $relevant_content,
6538 $session_id,
6539 $testing_data
6540 );
6541 } else {
6542 $response = $this->mxchat_generate_response_openai(
6543 $selected_model,
6544 $api_key,
6545 $conversation_history,
6546 $relevant_content
6547 );
6548 }
6549 break;
6550
6551 default:
6552 if (empty($api_key)) {
6553 $error_response = [
6554 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6555 'error_code' => 'missing_openai_api_key'
6556 ];
6557 if ($testing_data !== null) {
6558 $error_response['testing_data'] = $testing_data;
6559 }
6560 return $error_response;
6561 }
6562
6563 // Check if web search is enabled (default case also handles OpenAI models)
6564 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6565 $unsupported_web_search_models = array('gpt-4.1-nano');
6566 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6567
6568 if ($web_search_enabled && $model_supports_web_search) {
6569 return $this->mxchat_generate_response_openai_web_search(
6570 $selected_model,
6571 $api_key,
6572 $conversation_history,
6573 $relevant_content,
6574 $session_id,
6575 $testing_data,
6576 $streaming
6577 );
6578 } elseif ($streaming) {
6579 return $this->mxchat_generate_response_openai_stream(
6580 $selected_model,
6581 $api_key,
6582 $conversation_history,
6583 $relevant_content,
6584 $session_id,
6585 $testing_data
6586 );
6587 } else {
6588 $response = $this->mxchat_generate_response_openai(
6589 $selected_model,
6590 $api_key,
6591 $conversation_history,
6592 $relevant_content
6593 );
6594 }
6595 break;
6596 }
6597
6598 if (is_array($response) && isset($response['error'])) {
6599 if ($testing_data !== null) {
6600 $response['testing_data'] = $testing_data;
6601 }
6602 return $response;
6603 }
6604
6605 return $response;
6606
6607 } catch (Exception $e) {
6608 $error_response = [
6609 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
6610 'error_code' => 'system_exception',
6611 'exception_details' => $e->getMessage()
6612 ];
6613
6614 if ($testing_data !== null) {
6615 $error_response['testing_data'] = $testing_data;
6616 }
6617
6618 return $error_response;
6619 }
6620 }
6621 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6622 try {
6623 $bot_id = $this->get_current_bot_id($session_id);
6624 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6625
6626 if (!is_array($conversation_history)) {
6627 $conversation_history = array();
6628 }
6629
6630 $formatted_conversation = array();
6631
6632 $formatted_conversation[] = array(
6633 'role' => 'system',
6634 'content' => $system_prompt_instructions . " " . $relevant_content
6635 );
6636
6637 foreach ($conversation_history as $message) {
6638 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6639 $role = $message['role'];
6640 if ($role === 'bot' || $role === 'agent') {
6641 $role = 'assistant';
6642 }
6643 if (!in_array($role, ['system', 'assistant', 'user'])) {
6644 $role = 'user';
6645 }
6646 $formatted_conversation[] = array(
6647 'role' => $role,
6648 'content' => $message['content']
6649 );
6650 }
6651 }
6652
6653 if (headers_sent() || !function_exists('curl_init')) {
6654 $regular_response = $this->mxchat_generate_response_openrouter(
6655 $selected_model,
6656 $openrouter_api_key,
6657 $conversation_history,
6658 $relevant_content
6659 );
6660
6661 // Save bot response to transcript
6662 if (!empty($regular_response) && !empty($session_id)) {
6663 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6664 }
6665
6666 $response_data = [
6667 'text' => $regular_response,
6668 'html' => '',
6669 'session_id' => $session_id
6670 ];
6671
6672 if ($testing_data !== null) {
6673 $response_data['testing_data'] = $testing_data;
6674 }
6675
6676 header('Content-Type: application/json');
6677 echo json_encode($response_data);
6678 return true;
6679 }
6680
6681 $body = json_encode([
6682 'model' => $selected_model,
6683 'messages' => $formatted_conversation,
6684 'temperature' => 1,
6685 'stream' => true
6686 ]);
6687
6688 // Setup streaming headers now that we know we're actually streaming
6689 $this->setup_streaming_headers();
6690
6691 $ch = curl_init();
6692 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
6693 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6694 curl_setopt($ch, CURLOPT_POST, true);
6695 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6696 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6697 'Content-Type: application/json',
6698 'Authorization: Bearer ' . $openrouter_api_key,
6699 'HTTP-Referer: ' . home_url(),
6700 'X-Title: ' . get_bloginfo('name')
6701 ));
6702 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6703 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6704
6705 $full_response = '';
6706 $stream_started = false;
6707 $buffer = '';
6708
6709 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6710 if (!$stream_started && $testing_data !== null) {
6711 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6712 flush();
6713 $stream_started = true;
6714 }
6715
6716 $buffer .= $data;
6717 $lines = explode("\n", $buffer);
6718 $buffer = array_pop($lines);
6719
6720 foreach ($lines as $line) {
6721 if (trim($line) === '') {
6722 continue;
6723 }
6724
6725 if (strpos($line, 'data: ') !== 0) {
6726 continue;
6727 }
6728
6729 $json_str = substr($line, 6);
6730
6731 if (trim($json_str) === '[DONE]') {
6732 echo "data: [DONE]\n\n";
6733 flush();
6734 continue;
6735 }
6736
6737 $json = json_decode(trim($json_str), true);
6738 if ($json && isset($json['choices'][0]['delta']['content'])) {
6739 $content = $json['choices'][0]['delta']['content'];
6740 $full_response .= $content;
6741
6742 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6743 flush();
6744 }
6745 }
6746
6747 return strlen($data);
6748 });
6749
6750 $response = curl_exec($ch);
6751 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6752
6753 if (curl_errno($ch) || $http_code !== 200) {
6754 curl_close($ch);
6755
6756 $regular_response = $this->mxchat_generate_response_openrouter(
6757 $selected_model,
6758 $openrouter_api_key,
6759 $conversation_history,
6760 $relevant_content
6761 );
6762
6763 $response_data = [
6764 'text' => $regular_response,
6765 'html' => '',
6766 'session_id' => $session_id
6767 ];
6768
6769 if ($testing_data !== null) {
6770 $response_data['testing_data'] = $testing_data;
6771 }
6772
6773 header('Content-Type: application/json');
6774 echo json_encode($response_data);
6775 return true;
6776 }
6777
6778 curl_close($ch);
6779
6780 if (!empty($full_response) && !empty($session_id)) {
6781 // Prepare RAG context for streaming response
6782 $rag_context_for_storage = null;
6783 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
6784 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
6785
6786 if ($has_rag_data || $has_action_data) {
6787 $rag_context_for_storage = [];
6788
6789 if ($has_rag_data) {
6790 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
6791 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
6792 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
6793 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
6794 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
6795 }
6796
6797 if ($has_action_data) {
6798 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
6799 }
6800 }
6801 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6802 }
6803
6804 return true;
6805
6806 } catch (Exception $e) {
6807 $regular_response = $this->mxchat_generate_response_openrouter(
6808 $selected_model,
6809 $openrouter_api_key,
6810 $conversation_history,
6811 $relevant_content
6812 );
6813
6814 $response_data = [
6815 'text' => $regular_response,
6816 'html' => '',
6817 'session_id' => $session_id
6818 ];
6819
6820 if ($testing_data !== null) {
6821 $response_data['testing_data'] = $testing_data;
6822 }
6823
6824 header('Content-Type: application/json');
6825 echo json_encode($response_data);
6826 return true;
6827 }
6828 }
6829 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6830 try {
6831 $bot_id = $this->get_current_bot_id($session_id);
6832
6833 // Get system prompt instructions using centralized function
6834 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6835
6836 // Ensure conversation_history is an array
6837 if (!is_array($conversation_history)) {
6838 $conversation_history = array();
6839 }
6840
6841 // Format conversation history for OpenAI
6842 $formatted_conversation = array();
6843
6844 $formatted_conversation[] = array(
6845 'role' => 'system',
6846 'content' => $system_prompt_instructions . " " . $relevant_content
6847 );
6848
6849 foreach ($conversation_history as $message) {
6850 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6851 $role = $message['role'];
6852 if ($role === 'bot' || $role === 'agent') {
6853 $role = 'assistant';
6854 }
6855 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
6856 $role = 'user';
6857 }
6858 $formatted_conversation[] = array(
6859 'role' => $role,
6860 'content' => $message['content']
6861 );
6862 }
6863 }
6864
6865 // Check if we can actually stream
6866 if (headers_sent() || !function_exists('curl_init')) {
6867 // Fallback to regular response with testing data
6868 $regular_response = $this->mxchat_generate_response_openai(
6869 $selected_model,
6870 $api_key,
6871 $conversation_history,
6872 $relevant_content
6873 );
6874
6875 // Save bot response to transcript
6876 if (!empty($regular_response) && !empty($session_id)) {
6877 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6878 }
6879
6880 $response_data = [
6881 'text' => $regular_response,
6882 'html' => '',
6883 'session_id' => $session_id
6884 ];
6885
6886 if ($testing_data !== null) {
6887 $response_data['testing_data'] = $testing_data;
6888 }
6889
6890 header('Content-Type: application/json');
6891 echo json_encode($response_data);
6892 return true;
6893 }
6894
6895 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
6896 $is_gpt5_model = (
6897 strpos($selected_model, 'gpt-5') === 0 ||
6898 $selected_model === 'gpt-5.2' ||
6899 $selected_model === 'gpt-5.1-2025-11-13' ||
6900 $selected_model === 'gpt-5' ||
6901 $selected_model === 'gpt-5-mini' ||
6902 $selected_model === 'gpt-5-nano'
6903 );
6904
6905 // Build request body with optimal settings for fast streaming
6906 $request_body = [
6907 'model' => $selected_model,
6908 'messages' => $formatted_conversation,
6909 'temperature' => 1,
6910 'stream' => true
6911 ];
6912
6913 // Add reasoning_effort only for GPT-5 models that support it
6914 // These chat models don't support reasoning_effort parameter
6915 $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');
6916 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
6917 // GPT-5.1 uses 'low' instead of 'minimal'
6918 if ($selected_model === 'gpt-5.1-2025-11-13') {
6919 $request_body['reasoning_effort'] = 'low';
6920 } elseif ($selected_model === 'gpt-5.4') {
6921 $request_body['reasoning_effort'] = 'none';
6922 } else {
6923 $request_body['reasoning_effort'] = 'minimal';
6924 }
6925 }
6926
6927 $body = json_encode($request_body);
6928
6929 // Setup streaming headers now that we know we're actually streaming
6930 $this->setup_streaming_headers();
6931
6932 // Use cURL for streaming support
6933 $ch = curl_init();
6934 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
6935 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6936 curl_setopt($ch, CURLOPT_POST, true);
6937 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6938 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6939 'Content-Type: application/json',
6940 'Authorization: Bearer ' . $api_key
6941 ));
6942 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6943 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6944
6945 $full_response = ''; // Accumulate full response for saving
6946 $stream_started = false;
6947 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
6948
6949 // Buffer control for real-time streaming
6950 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6951 // Send testing data as the first event if available
6952 if (!$stream_started && $testing_data !== null) {
6953 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6954 flush();
6955 $stream_started = true;
6956 }
6957
6958 // CRITICAL FIX: Append new data to buffer
6959 $buffer .= $data;
6960
6961 // Process complete lines only
6962 $lines = explode("\n", $buffer);
6963
6964 // CRITICAL FIX: Keep the last incomplete line in the buffer
6965 // The last element might be incomplete, so keep it in buffer
6966 $buffer = array_pop($lines);
6967
6968 foreach ($lines as $line) {
6969 // Skip empty lines
6970 if (trim($line) === '') {
6971 continue;
6972 }
6973
6974 // Only process lines that start with "data: "
6975 if (strpos($line, 'data: ') !== 0) {
6976 continue;
6977 }
6978
6979 $json_str = substr($line, 6); // Remove 'data: ' prefix
6980
6981 if (trim($json_str) === '[DONE]') {
6982 echo "data: [DONE]\n\n";
6983 flush();
6984 continue;
6985 }
6986
6987 // Try to decode JSON
6988 $json = json_decode(trim($json_str), true);
6989 if ($json && isset($json['choices'][0]['delta']['content'])) {
6990 $content = $json['choices'][0]['delta']['content'];
6991 $full_response .= $content; // Accumulate the full response
6992
6993 // Send as SSE format
6994 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6995 flush();
6996 }
6997 }
6998
6999 return strlen($data);
7000 });
7001
7002 $response = curl_exec($ch);
7003 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7004
7005 if (curl_errno($ch) || $http_code !== 200) {
7006 $curl_error = curl_error($ch);
7007 curl_close($ch);
7008
7009 // Fallback to regular response
7010 $regular_response = $this->mxchat_generate_response_openai(
7011 $selected_model,
7012 $api_key,
7013 $conversation_history,
7014 $relevant_content
7015 );
7016
7017 // FIXED: Check if regular response returned an error
7018 if (is_array($regular_response) && isset($regular_response['error'])) {
7019 // Send error in SSE format since we're in streaming mode
7020 echo "data: " . json_encode([
7021 'error' => true,
7022 'error_message' => $regular_response['error'],
7023 'error_code' => $regular_response['error_code'] ?? 'api_error',
7024 'text' => $regular_response['error'],
7025 'message' => $regular_response['error']
7026 ]) . "\n\n";
7027 echo "data: [DONE]\n\n";
7028 flush();
7029 return true;
7030 }
7031
7032 $response_data = [
7033 'text' => $regular_response,
7034 'html' => '',
7035 'session_id' => $session_id
7036 ];
7037
7038 if ($testing_data !== null) {
7039 $response_data['testing_data'] = $testing_data;
7040 }
7041
7042 header('Content-Type: application/json');
7043 echo json_encode($response_data);
7044 return true;
7045 }
7046
7047 curl_close($ch);
7048
7049 // Save the complete response to maintain chat persistence
7050 if (!empty($full_response) && !empty($session_id)) {
7051 // Prepare RAG context for streaming response
7052 $rag_context_for_storage = null;
7053 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7054 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7055
7056 if ($has_rag_data || $has_action_data) {
7057 $rag_context_for_storage = [];
7058
7059 if ($has_rag_data) {
7060 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7061 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7062 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7063 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7064 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7065 }
7066
7067 if ($has_action_data) {
7068 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7069 }
7070 }
7071 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7072 }
7073
7074 return true; // Indicate streaming completed successfully
7075
7076 } catch (Exception $e) {
7077 // Fallback to regular response
7078 $regular_response = $this->mxchat_generate_response_openai(
7079 $selected_model,
7080 $api_key,
7081 $conversation_history,
7082 $relevant_content
7083 );
7084
7085 // FIXED: Check if regular response returned an error
7086 if (is_array($regular_response) && isset($regular_response['error'])) {
7087 // Send error in SSE format since we're in streaming mode
7088 echo "data: " . json_encode([
7089 'error' => true,
7090 'error_message' => $regular_response['error'],
7091 'error_code' => $regular_response['error_code'] ?? 'api_error',
7092 'text' => $regular_response['error'],
7093 'message' => $regular_response['error']
7094 ]) . "\n\n";
7095 echo "data: [DONE]\n\n";
7096 flush();
7097 return true;
7098 }
7099
7100 $response_data = [
7101 'text' => $regular_response,
7102 'html' => '',
7103 'session_id' => $session_id
7104 ];
7105
7106 if ($testing_data !== null) {
7107 $response_data['testing_data'] = $testing_data;
7108 }
7109
7110 header('Content-Type: application/json');
7111 echo json_encode($response_data);
7112 return true;
7113 }
7114 }
7115
7116 /**
7117 * Generate response using OpenAI Responses API with web search tool
7118 * This uses the newer Responses API which supports web search functionality
7119 */
7120 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
7121 try {
7122 $bot_id = $this->get_current_bot_id($session_id);
7123 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7124
7125 if (!is_array($conversation_history)) {
7126 $conversation_history = array();
7127 }
7128
7129 // Build the input for Responses API
7130 // The Responses API uses a different format - we need to construct the input properly
7131 $input_parts = [];
7132
7133 // Add system instructions as context
7134 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
7135
7136 // Build conversation as input items for Responses API
7137 foreach ($conversation_history as $message) {
7138 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7139 $role = $message['role'];
7140 if ($role === 'bot' || $role === 'agent') {
7141 $role = 'assistant';
7142 }
7143 if (!in_array($role, ['assistant', 'user'])) {
7144 $role = 'user';
7145 }
7146 $input_parts[] = [
7147 'type' => 'message',
7148 'role' => $role,
7149 'content' => $message['content']
7150 ];
7151 }
7152 }
7153
7154 // Build request body for Responses API
7155 $request_body = [
7156 'model' => $selected_model,
7157 'input' => $input_parts,
7158 'instructions' => $system_context,
7159 'stream' => $streaming
7160 ];
7161
7162 // Only add web search tool if web search is enabled in settings
7163 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
7164 if ($web_search_enabled) {
7165 $request_body['tools'] = [
7166 ['type' => 'web_search']
7167 ];
7168 }
7169
7170 // Add reasoning effort for supported models
7171 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
7172 $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
7173 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_web, true)) {
7174 if ($selected_model === 'gpt-5.1-2025-11-13') {
7175 $request_body['reasoning'] = ['effort' => 'low'];
7176 } elseif ($selected_model === 'gpt-5.4') {
7177 $request_body['reasoning'] = ['effort' => 'low'];
7178 }
7179 }
7180
7181 //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
7182
7183 if ($streaming) {
7184 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
7185 } else {
7186 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7187 }
7188
7189 } catch (Exception $e) {
7190 //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
7191 return [
7192 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
7193 'error_code' => 'web_search_exception'
7194 ];
7195 }
7196 }
7197
7198 /**
7199 * Handle non-streaming web search response
7200 */
7201 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7202 $request_body['stream'] = false;
7203
7204 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
7205 'headers' => array(
7206 'Authorization' => 'Bearer ' . $api_key,
7207 'Content-Type' => 'application/json'
7208 ),
7209 'body' => json_encode($request_body),
7210 'timeout' => 90
7211 ));
7212
7213 if (is_wp_error($response)) {
7214 //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
7215 return [
7216 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
7217 'error_code' => 'web_search_connection_error'
7218 ];
7219 }
7220
7221 $response_code = wp_remote_retrieve_response_code($response);
7222 $response_body = wp_remote_retrieve_body($response);
7223
7224 //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
7225 //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
7226
7227 if ($response_code !== 200) {
7228 $error_data = json_decode($response_body, true);
7229 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
7230 return [
7231 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
7232 'error_code' => 'web_search_api_error'
7233 ];
7234 }
7235
7236 $result = json_decode($response_body, true);
7237
7238 if (json_last_error() !== JSON_ERROR_NONE) {
7239 return [
7240 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
7241 'error_code' => 'web_search_json_error'
7242 ];
7243 }
7244
7245 // Extract the response text and citations from Responses API format
7246 $output_text = '';
7247 $citations = [];
7248
7249 if (isset($result['output'])) {
7250 foreach ($result['output'] as $output_item) {
7251 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
7252 foreach ($output_item['content'] as $content_item) {
7253 if ($content_item['type'] === 'output_text') {
7254 $output_text .= $content_item['text'];
7255
7256 // Extract citations/annotations
7257 if (isset($content_item['annotations'])) {
7258 foreach ($content_item['annotations'] as $annotation) {
7259 if ($annotation['type'] === 'url_citation') {
7260 $citations[] = [
7261 'url' => $annotation['url'],
7262 'title' => $annotation['title'] ?? ''
7263 ];
7264 }
7265 }
7266 }
7267 }
7268 }
7269 }
7270 }
7271 }
7272
7273 // If we have citations, append them to the response
7274 if (!empty($citations)) {
7275 $output_text .= "\n\n**Sources:**\n";
7276 $seen_urls = [];
7277 foreach ($citations as $citation) {
7278 if (!in_array($citation['url'], $seen_urls)) {
7279 $seen_urls[] = $citation['url'];
7280 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7281 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7282 }
7283 }
7284 }
7285
7286 // Save to transcript
7287 if (!empty($output_text) && !empty($session_id)) {
7288 $this->mxchat_save_chat_message($session_id, 'bot', $output_text);
7289 }
7290
7291 return $output_text;
7292 }
7293
7294 /**
7295 * Handle streaming web search response using Responses API
7296 */
7297 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7298 $request_body['stream'] = true;
7299
7300 // Check if we can stream
7301 if (headers_sent() || !function_exists('curl_init')) {
7302 // Fallback to non-streaming
7303 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7304 }
7305
7306 // Setup streaming headers
7307 $this->setup_streaming_headers();
7308
7309 $ch = curl_init();
7310 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
7311 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7312 curl_setopt($ch, CURLOPT_POST, true);
7313 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
7314 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7315 'Content-Type: application/json',
7316 'Authorization: Bearer ' . $api_key
7317 ));
7318 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7319 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
7320
7321 $full_response = '';
7322 $stream_started = false;
7323 $buffer = '';
7324 $citations = [];
7325
7326 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
7327 // Send testing data as first event if available
7328 if (!$stream_started && $testing_data !== null) {
7329 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7330 flush();
7331 $stream_started = true;
7332 }
7333
7334 $buffer .= $data;
7335 $lines = explode("\n", $buffer);
7336 $buffer = array_pop($lines);
7337
7338 foreach ($lines as $line) {
7339 if (trim($line) === '') continue;
7340 if (strpos($line, 'data: ') !== 0) continue;
7341
7342 $json_str = substr($line, 6);
7343
7344 if (trim($json_str) === '[DONE]') {
7345 // Append citations if we have any
7346 if (!empty($citations)) {
7347 $citation_text = "\n\n**Sources:**\n";
7348 $seen_urls = [];
7349 foreach ($citations as $citation) {
7350 if (!in_array($citation['url'], $seen_urls)) {
7351 $seen_urls[] = $citation['url'];
7352 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7353 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7354 }
7355 }
7356 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
7357 $full_response .= $citation_text;
7358 flush();
7359 }
7360 echo "data: [DONE]\n\n";
7361 flush();
7362 continue;
7363 }
7364
7365 $json = json_decode(trim($json_str), true);
7366 if (!$json) continue;
7367
7368 // Handle Responses API streaming events
7369 // The format is different from Chat Completions
7370 if (isset($json['type'])) {
7371 switch ($json['type']) {
7372 case 'response.output_text.delta':
7373 // Text content delta
7374 if (isset($json['delta'])) {
7375 $content = $json['delta'];
7376 $full_response .= $content;
7377 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7378 flush();
7379 }
7380 break;
7381
7382 case 'response.output_item.done':
7383 // Check for citations in completed items
7384 if (isset($json['item']['content'])) {
7385 foreach ($json['item']['content'] as $content_item) {
7386 if (isset($content_item['annotations'])) {
7387 foreach ($content_item['annotations'] as $annotation) {
7388 if ($annotation['type'] === 'url_citation') {
7389 $citations[] = [
7390 'url' => $annotation['url'],
7391 'title' => $annotation['title'] ?? ''
7392 ];
7393 }
7394 }
7395 }
7396 }
7397 }
7398 break;
7399 }
7400 }
7401 }
7402
7403 return strlen($data);
7404 });
7405
7406 $response = curl_exec($ch);
7407 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7408
7409 if (curl_errno($ch) || $http_code !== 200) {
7410 $curl_error = curl_error($ch);
7411 curl_close($ch);
7412
7413 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
7414
7415 // Fallback to non-streaming
7416 $fallback_response = $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7417
7418 if (is_array($fallback_response) && isset($fallback_response['error'])) {
7419 echo "data: " . json_encode([
7420 'error' => true,
7421 'error_message' => $fallback_response['error'],
7422 'error_code' => $fallback_response['error_code'] ?? 'web_search_error'
7423 ]) . "\n\n";
7424 echo "data: [DONE]\n\n";
7425 flush();
7426 return true;
7427 }
7428
7429 $response_data = [
7430 'text' => $fallback_response,
7431 'html' => '',
7432 'session_id' => $session_id
7433 ];
7434 if ($testing_data !== null) {
7435 $response_data['testing_data'] = $testing_data;
7436 }
7437 header('Content-Type: application/json');
7438 echo json_encode($response_data);
7439 return true;
7440 }
7441
7442 curl_close($ch);
7443
7444 // Save the complete response
7445 if (!empty($full_response) && !empty($session_id)) {
7446 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
7447 }
7448
7449 return true;
7450 }
7451
7452 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7453 try {
7454 // Get bot ID from session or request
7455 $bot_id = $this->get_current_bot_id($session_id);
7456
7457 // Get system prompt instructions using centralized function
7458 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7459 // Ensure conversation_history is an array
7460 if (!is_array($conversation_history)) {
7461 $conversation_history = array();
7462 }
7463
7464 // Clean and validate conversation history
7465 foreach ($conversation_history as &$message) {
7466 // Convert bot and agent roles to assistant
7467 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7468 $message['role'] = 'assistant';
7469 }
7470
7471 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7472 if (!in_array($message['role'], ['assistant', 'user'])) {
7473 $message['role'] = 'user';
7474 }
7475
7476 // Ensure content field exists
7477 if (!isset($message['content']) || empty($message['content'])) {
7478 $message['content'] = '';
7479 }
7480
7481 // Remove any unsupported fields
7482 $message = array_intersect_key($message, array_flip(['role', 'content']));
7483 }
7484
7485 // Add relevant content as the latest user message
7486 $conversation_history[] = [
7487 'role' => 'user',
7488 'content' => $relevant_content
7489 ];
7490
7491 // Prepare the request body with stream: true
7492 $body = json_encode([
7493 'model' => $selected_model,
7494 'messages' => $conversation_history,
7495 'max_tokens' => 1000,
7496 'temperature' => 0.8,
7497 'system' => $system_prompt_instructions,
7498 'stream' => true
7499 ]);
7500
7501 // Check if we can actually stream (headers not sent, etc.)
7502 if (headers_sent() || !function_exists('curl_init')) {
7503 // Fallback to regular response with testing data
7504 //error_log("MxChat: Streaming not possible, falling back to regular response");
7505 $regular_response = $this->mxchat_generate_response_claude(
7506 $selected_model,
7507 $claude_api_key,
7508 array_slice($conversation_history, 0, -1), // Remove the added content
7509 $relevant_content
7510 );
7511
7512 // Save bot response to transcript
7513 if (!empty($regular_response) && !empty($session_id)) {
7514 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7515 }
7516
7517 // Return as JSON with testing data
7518 $response_data = [
7519 'text' => $regular_response,
7520 'html' => '',
7521 'session_id' => $session_id
7522 ];
7523
7524 if ($testing_data !== null) {
7525 $response_data['testing_data'] = $testing_data;
7526 //error_log("MxChat Testing: Added testing data to Claude fallback response");
7527 }
7528
7529 // Clear any streaming headers and send JSON
7530 if (headers_sent() === false) {
7531 header('Content-Type: application/json');
7532 }
7533 echo json_encode($response_data);
7534 return true; // Indicate we handled the response
7535 }
7536
7537 // Setup streaming headers now that we know we're actually streaming
7538 $this->setup_streaming_headers();
7539
7540 // Use cURL for streaming support
7541 $ch = curl_init();
7542 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
7543 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7544 curl_setopt($ch, CURLOPT_POST, true);
7545 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7546 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7547 'Content-Type: application/json',
7548 'x-api-key: ' . $claude_api_key,
7549 'anthropic-version: 2023-06-01'
7550 ));
7551 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7552 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7553
7554 $full_response = ''; // Accumulate full response for saving
7555 $stream_started = false;
7556 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7557
7558 // Buffer control for real-time streaming
7559 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7560 // Send testing data as the first event if available
7561 if (!$stream_started && $testing_data !== null) {
7562 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7563 flush();
7564 $stream_started = true;
7565 //error_log("MxChat Testing: Sent testing data in Claude stream");
7566 }
7567
7568 // CRITICAL FIX: Append new data to buffer
7569 $buffer .= $data;
7570
7571 // Process complete lines only
7572 $lines = explode("\n", $buffer);
7573
7574 // CRITICAL FIX: Keep the last incomplete line in the buffer
7575 // The last element might be incomplete, so keep it in buffer
7576 $buffer = array_pop($lines);
7577
7578 foreach ($lines as $line) {
7579 if (trim($line) === '') {
7580 continue;
7581 }
7582
7583 // Claude uses event: and data: format
7584 if (strpos($line, 'event: ') === 0) {
7585 // Store the event type for the next data line
7586 continue;
7587 }
7588
7589 if (strpos($line, 'data: ') === 0) {
7590 $json_str = substr($line, 6); // Remove 'data: ' prefix
7591
7592 $json = json_decode(trim($json_str), true);
7593 if (json_last_error() !== JSON_ERROR_NONE) {
7594 continue;
7595 }
7596
7597 // Handle different event types
7598 if (isset($json['type'])) {
7599 switch ($json['type']) {
7600 case 'content_block_delta':
7601 if (isset($json['delta']['text'])) {
7602 $content = $json['delta']['text'];
7603 $full_response .= $content; // Accumulate
7604 // Send as SSE format compatible with your frontend
7605 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7606 flush();
7607 }
7608 break;
7609
7610 case 'message_stop':
7611 echo "data: [DONE]\n\n";
7612 flush();
7613 break;
7614
7615 case 'error':
7616 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
7617 flush();
7618 break;
7619 }
7620 }
7621 }
7622 }
7623
7624 return strlen($data);
7625 });
7626
7627 $response = curl_exec($ch);
7628 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7629
7630 if (curl_errno($ch)) {
7631 curl_close($ch);
7632 throw new Exception('cURL Error: ' . curl_error($ch));
7633 }
7634
7635 curl_close($ch);
7636
7637 if ($http_code !== 200) {
7638 // Fallback to regular response
7639 //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
7640 $regular_response = $this->mxchat_generate_response_claude(
7641 $selected_model,
7642 $claude_api_key,
7643 array_slice($conversation_history, 0, -1), // Remove the added content
7644 $relevant_content
7645 );
7646
7647 // FIXED: Check if regular response returned an error
7648 if (is_array($regular_response) && isset($regular_response['error'])) {
7649 // Send error in SSE format since we're in streaming mode
7650 echo "data: " . json_encode([
7651 'error' => true,
7652 'error_message' => $regular_response['error'],
7653 'error_code' => $regular_response['error_code'] ?? 'api_error',
7654 'text' => $regular_response['error'],
7655 'message' => $regular_response['error']
7656 ]) . "\n\n";
7657 echo "data: [DONE]\n\n";
7658 flush();
7659 return true;
7660 }
7661
7662 $response_data = [
7663 'text' => $regular_response,
7664 'html' => '',
7665 'session_id' => $session_id
7666 ];
7667
7668 if ($testing_data !== null) {
7669 $response_data['testing_data'] = $testing_data;
7670 //error_log("MxChat Testing: Added testing data to Claude error fallback");
7671 }
7672
7673 header('Content-Type: application/json');
7674 echo json_encode($response_data);
7675 return true;
7676 }
7677
7678 // Save the complete response to maintain chat persistence
7679 if (!empty($full_response) && !empty($session_id)) {
7680 // Prepare RAG context for streaming response
7681 $rag_context_for_storage = null;
7682 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7683 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7684
7685 if ($has_rag_data || $has_action_data) {
7686 $rag_context_for_storage = [];
7687
7688 if ($has_rag_data) {
7689 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7690 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7691 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7692 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7693 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7694 }
7695
7696 if ($has_action_data) {
7697 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7698 }
7699 }
7700 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7701 }
7702
7703 return true; // Indicate streaming completed successfully
7704
7705 } catch (Exception $e) {
7706 //error_log("MxChat Claude streaming exception: " . $e->getMessage());
7707
7708 // Fallback to regular response on exception
7709 $regular_response = $this->mxchat_generate_response_claude(
7710 $selected_model,
7711 $claude_api_key,
7712 $conversation_history,
7713 $relevant_content
7714 );
7715
7716 // FIXED: Check if regular response returned an error
7717 if (is_array($regular_response) && isset($regular_response['error'])) {
7718 // Send error in SSE format since we're in streaming mode
7719 echo "data: " . json_encode([
7720 'error' => true,
7721 'error_message' => $regular_response['error'],
7722 'error_code' => $regular_response['error_code'] ?? 'api_error',
7723 'text' => $regular_response['error'],
7724 'message' => $regular_response['error']
7725 ]) . "\n\n";
7726 echo "data: [DONE]\n\n";
7727 flush();
7728 return true;
7729 }
7730
7731 $response_data = [
7732 'text' => $regular_response,
7733 'html' => '',
7734 'session_id' => $session_id
7735 ];
7736
7737 if ($testing_data !== null) {
7738 $response_data['testing_data'] = $testing_data;
7739 //error_log("MxChat Testing: Added testing data to Claude exception fallback");
7740 }
7741
7742 header('Content-Type: application/json');
7743 echo json_encode($response_data);
7744 return true;
7745 }
7746 }
7747 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7748 try {
7749 // Get bot ID from session or request
7750 $bot_id = $this->get_current_bot_id($session_id);
7751
7752 // Get system prompt instructions using centralized function
7753 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7754
7755 // Ensure conversation_history is an array
7756 if (!is_array($conversation_history)) {
7757 $conversation_history = array();
7758 }
7759
7760 // Format conversation history for X.AI (same as OpenAI format)
7761 $formatted_conversation = array();
7762
7763 $formatted_conversation[] = array(
7764 'role' => 'system',
7765 'content' => $system_prompt_instructions . " " . $relevant_content
7766 );
7767
7768 foreach ($conversation_history as $message) {
7769 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7770 $role = $message['role'];
7771 if ($role === 'bot' || $role === 'agent') {
7772 $role = 'assistant';
7773 }
7774 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7775 $role = 'user';
7776 }
7777 $formatted_conversation[] = array(
7778 'role' => $role,
7779 'content' => $message['content']
7780 );
7781 }
7782 }
7783
7784 // Check if we can actually stream
7785 if (headers_sent() || !function_exists('curl_init')) {
7786 // Fallback to regular response with testing data
7787 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
7788 $regular_response = $this->mxchat_generate_response_xai(
7789 $selected_model,
7790 $xai_api_key,
7791 $conversation_history,
7792 $relevant_content
7793 );
7794
7795 // Save bot response to transcript
7796 if (!empty($regular_response) && !empty($session_id)) {
7797 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7798 }
7799
7800 $response_data = [
7801 'text' => $regular_response,
7802 'html' => '',
7803 'session_id' => $session_id
7804 ];
7805
7806 if ($testing_data !== null) {
7807 $response_data['testing_data'] = $testing_data;
7808 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
7809 }
7810
7811 header('Content-Type: application/json');
7812 echo json_encode($response_data);
7813 return true;
7814 }
7815
7816 // Prepare the request body with stream: true
7817 $body = json_encode([
7818 'model' => $selected_model,
7819 'messages' => $formatted_conversation,
7820 'temperature' => 0.8,
7821 'stream' => true
7822 ]);
7823
7824 // Setup streaming headers now that we know we're actually streaming
7825 $this->setup_streaming_headers();
7826
7827 // Use cURL for streaming support
7828 $ch = curl_init();
7829 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
7830 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7831 curl_setopt($ch, CURLOPT_POST, true);
7832 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7833 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7834 'Content-Type: application/json',
7835 'Authorization: Bearer ' . $xai_api_key
7836 ));
7837 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7838 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7839
7840 $full_response = ''; // Accumulate full response for saving
7841 $stream_started = false;
7842 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7843
7844 // Buffer control for real-time streaming
7845 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7846 // Send testing data as the first event if available
7847 if (!$stream_started && $testing_data !== null) {
7848 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7849 flush();
7850 $stream_started = true;
7851 //error_log("MxChat Testing: Sent testing data in X.AI stream");
7852 }
7853
7854 // CRITICAL FIX: Append new data to buffer
7855 $buffer .= $data;
7856
7857 // Process complete lines only
7858 $lines = explode("\n", $buffer);
7859
7860 // CRITICAL FIX: Keep the last incomplete line in the buffer
7861 // The last element might be incomplete, so keep it in buffer
7862 $buffer = array_pop($lines);
7863
7864 foreach ($lines as $line) {
7865 // Skip empty lines
7866 if (trim($line) === '') {
7867 continue;
7868 }
7869
7870 // Only process lines that start with "data: "
7871 if (strpos($line, 'data: ') !== 0) {
7872 continue;
7873 }
7874
7875 $json_str = substr($line, 6); // Remove 'data: ' prefix
7876
7877 if (trim($json_str) === '[DONE]') {
7878 echo "data: [DONE]\n\n";
7879 flush();
7880 continue;
7881 }
7882
7883 // Try to decode JSON
7884 $json = json_decode(trim($json_str), true);
7885 if ($json && isset($json['choices'][0]['delta']['content'])) {
7886 $content = $json['choices'][0]['delta']['content'];
7887 $full_response .= $content; // Accumulate
7888 // Send as SSE format
7889 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7890 flush();
7891 }
7892 }
7893
7894 return strlen($data);
7895 });
7896
7897 $response = curl_exec($ch);
7898 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7899
7900 if (curl_errno($ch) || $http_code !== 200) {
7901 curl_close($ch);
7902
7903 // Fallback to regular response
7904 //error_log("MxChat: X.AI streaming failed, falling back");
7905 $regular_response = $this->mxchat_generate_response_xai(
7906 $selected_model,
7907 $xai_api_key,
7908 $conversation_history,
7909 $relevant_content
7910 );
7911
7912 $response_data = [
7913 'text' => $regular_response,
7914 'html' => '',
7915 'session_id' => $session_id
7916 ];
7917
7918 if ($testing_data !== null) {
7919 $response_data['testing_data'] = $testing_data;
7920 //error_log("MxChat Testing: Added testing data to X.AI error fallback");
7921 }
7922
7923 header('Content-Type: application/json');
7924 echo json_encode($response_data);
7925 return true;
7926 }
7927
7928 curl_close($ch);
7929
7930 // Save the complete response to maintain chat persistence
7931 if (!empty($full_response) && !empty($session_id)) {
7932 // Prepare RAG context for streaming response
7933 $rag_context_for_storage = null;
7934 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7935 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7936
7937 if ($has_rag_data || $has_action_data) {
7938 $rag_context_for_storage = [];
7939
7940 if ($has_rag_data) {
7941 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7942 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7943 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7944 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7945 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7946 }
7947
7948 if ($has_action_data) {
7949 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7950 }
7951 }
7952 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7953 }
7954
7955 return true; // Indicate streaming completed successfully
7956
7957 } catch (Exception $e) {
7958 //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
7959
7960 // Fallback to regular response
7961 $regular_response = $this->mxchat_generate_response_xai(
7962 $selected_model,
7963 $xai_api_key,
7964 $conversation_history,
7965 $relevant_content
7966 );
7967
7968 $response_data = [
7969 'text' => $regular_response,
7970 'html' => '',
7971 'session_id' => $session_id
7972 ];
7973
7974 if ($testing_data !== null) {
7975 $response_data['testing_data'] = $testing_data;
7976 //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
7977 }
7978
7979 header('Content-Type: application/json');
7980 echo json_encode($response_data);
7981 return true;
7982 }
7983 }
7984 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7985 try {
7986 // Get bot ID from session or request
7987 $bot_id = $this->get_current_bot_id($session_id);
7988
7989 // Get system prompt instructions using centralized function
7990 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7991
7992 // Ensure conversation_history is an array
7993 if (!is_array($conversation_history)) {
7994 $conversation_history = array();
7995 }
7996
7997 // Format conversation history for DeepSeek
7998 $formatted_conversation = array();
7999
8000 $formatted_conversation[] = array(
8001 'role' => 'system',
8002 'content' => $system_prompt_instructions . " " . $relevant_content
8003 );
8004
8005 foreach ($conversation_history as $message) {
8006 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8007 $role = $message['role'];
8008 if ($role === 'bot' || $role === 'agent') {
8009 $role = 'assistant';
8010 }
8011 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8012 $role = 'user';
8013 }
8014 $formatted_conversation[] = array(
8015 'role' => $role,
8016 'content' => $message['content']
8017 );
8018 }
8019 }
8020
8021 // Check if we can actually stream
8022 if (headers_sent() || !function_exists('curl_init')) {
8023 // Fallback to regular response with testing data
8024 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
8025 $regular_response = $this->mxchat_generate_response_deepseek(
8026 $selected_model,
8027 $deepseek_api_key,
8028 $conversation_history,
8029 $relevant_content
8030 );
8031
8032 // Save bot response to transcript
8033 if (!empty($regular_response) && !empty($session_id)) {
8034 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8035 }
8036
8037 $response_data = [
8038 'text' => $regular_response,
8039 'html' => '',
8040 'session_id' => $session_id
8041 ];
8042
8043 if ($testing_data !== null) {
8044 $response_data['testing_data'] = $testing_data;
8045 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
8046 }
8047
8048 header('Content-Type: application/json');
8049 echo json_encode($response_data);
8050 return true;
8051 }
8052
8053 // Prepare the request body with stream: true
8054 $body = json_encode([
8055 'model' => $selected_model,
8056 'messages' => $formatted_conversation,
8057 'temperature' => 0.8,
8058 'stream' => true
8059 ]);
8060
8061 // Setup streaming headers now that we know we're actually streaming
8062 $this->setup_streaming_headers();
8063
8064 // Use cURL for streaming support
8065 $ch = curl_init();
8066 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
8067 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8068 curl_setopt($ch, CURLOPT_POST, true);
8069 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8070 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8071 'Content-Type: application/json',
8072 'Authorization: Bearer ' . $deepseek_api_key
8073 ));
8074 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8075 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8076
8077 $full_response = ''; // Accumulate full response for saving
8078 $stream_started = false;
8079 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
8080
8081 // Buffer control for real-time streaming
8082 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
8083 // Send testing data as the first event if available
8084 if (!$stream_started && $testing_data !== null) {
8085 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8086 flush();
8087 $stream_started = true;
8088 //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
8089 }
8090
8091 // CRITICAL FIX: Append new data to buffer
8092 $buffer .= $data;
8093
8094 // Process complete lines only
8095 $lines = explode("\n", $buffer);
8096
8097 // CRITICAL FIX: Keep the last incomplete line in the buffer
8098 // The last element might be incomplete, so keep it in buffer
8099 $buffer = array_pop($lines);
8100
8101 foreach ($lines as $line) {
8102 // Skip empty lines
8103 if (trim($line) === '') {
8104 continue;
8105 }
8106
8107 // Only process lines that start with "data: "
8108 if (strpos($line, 'data: ') !== 0) {
8109 continue;
8110 }
8111
8112 $json_str = substr($line, 6); // Remove 'data: ' prefix
8113
8114 if (trim($json_str) === '[DONE]') {
8115 echo "data: [DONE]\n\n";
8116 flush();
8117 continue;
8118 }
8119
8120 // Try to decode JSON
8121 $json = json_decode(trim($json_str), true);
8122 if ($json && isset($json['choices'][0]['delta']['content'])) {
8123 $content = $json['choices'][0]['delta']['content'];
8124 $full_response .= $content; // Accumulate the full response
8125
8126 // Send as SSE format
8127 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8128 flush();
8129 }
8130 }
8131
8132 return strlen($data);
8133 });
8134
8135 $response = curl_exec($ch);
8136 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8137
8138 if (curl_errno($ch) || $http_code !== 200) {
8139 $curl_error = curl_error($ch);
8140 curl_close($ch);
8141
8142 // Log the specific error for debugging
8143 //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
8144
8145 // Fallback to regular response
8146 $regular_response = $this->mxchat_generate_response_deepseek(
8147 $selected_model,
8148 $deepseek_api_key,
8149 $conversation_history,
8150 $relevant_content
8151 );
8152
8153 // Handle error response from regular function
8154 if (is_array($regular_response) && isset($regular_response['error'])) {
8155 if ($testing_data !== null) {
8156 $regular_response['testing_data'] = $testing_data;
8157 }
8158 header('Content-Type: application/json');
8159 echo json_encode($regular_response);
8160 return true;
8161 }
8162
8163 $response_data = [
8164 'text' => $regular_response,
8165 'html' => '',
8166 'session_id' => $session_id
8167 ];
8168
8169 if ($testing_data !== null) {
8170 $response_data['testing_data'] = $testing_data;
8171 //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
8172 }
8173
8174 header('Content-Type: application/json');
8175 echo json_encode($response_data);
8176 return true;
8177 }
8178
8179 curl_close($ch);
8180
8181 // Save the complete response to maintain chat persistence
8182 if (!empty($full_response) && !empty($session_id)) {
8183 // Prepare RAG context for streaming response
8184 $rag_context_for_storage = null;
8185 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8186 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8187
8188 if ($has_rag_data || $has_action_data) {
8189 $rag_context_for_storage = [];
8190
8191 if ($has_rag_data) {
8192 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8193 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8194 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8195 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8196 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8197 }
8198
8199 if ($has_action_data) {
8200 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8201 }
8202 }
8203 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8204 }
8205
8206 return true; // Indicate streaming completed successfully
8207
8208 } catch (Exception $e) {
8209 //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
8210
8211 // Fallback to regular response
8212 $regular_response = $this->mxchat_generate_response_deepseek(
8213 $selected_model,
8214 $deepseek_api_key,
8215 $conversation_history,
8216 $relevant_content
8217 );
8218
8219 // Handle error response from regular function
8220 if (is_array($regular_response) && isset($regular_response['error'])) {
8221 if ($testing_data !== null) {
8222 $regular_response['testing_data'] = $testing_data;
8223 }
8224 header('Content-Type: application/json');
8225 echo json_encode($regular_response);
8226 return true;
8227 }
8228
8229 $response_data = [
8230 'text' => $regular_response,
8231 'html' => '',
8232 'session_id' => $session_id
8233 ];
8234
8235 if ($testing_data !== null) {
8236 $response_data['testing_data'] = $testing_data;
8237 //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
8238 }
8239
8240 header('Content-Type: application/json');
8241 echo json_encode($response_data);
8242 return true;
8243 }
8244 }
8245
8246
8247 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
8248 try {
8249 if (!is_array($conversation_history)) {
8250 $conversation_history = array();
8251 }
8252
8253 $bot_id = $this->get_current_bot_id('');
8254 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8255
8256 $formatted_conversation = array();
8257
8258 $formatted_conversation[] = array(
8259 'role' => 'system',
8260 'content' => $system_prompt_instructions . " " . $relevant_content
8261 );
8262
8263 foreach ($conversation_history as $message) {
8264 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8265 $role = $message['role'];
8266
8267 if ($role === 'bot' || $role === 'agent') {
8268 $role = 'assistant';
8269 }
8270 if (!in_array($role, ['system', 'assistant', 'user'])) {
8271 $role = 'user';
8272 }
8273
8274 $formatted_conversation[] = array(
8275 'role' => $role,
8276 'content' => $message['content']
8277 );
8278 }
8279 }
8280
8281 $body = json_encode([
8282 'model' => $selected_model,
8283 'messages' => $formatted_conversation,
8284 'temperature' => 1,
8285 ]);
8286
8287 $args = [
8288 'body' => $body,
8289 'headers' => [
8290 'Content-Type' => 'application/json',
8291 'Authorization' => 'Bearer ' . $openrouter_api_key,
8292 'HTTP-Referer' => home_url(),
8293 'X-Title' => get_bloginfo('name'),
8294 ],
8295 'timeout' => 60,
8296 'redirection' => 5,
8297 'blocking' => true,
8298 'httpversion' => '1.0',
8299 'sslverify' => true,
8300 ];
8301
8302 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', $args);
8303
8304 if (is_wp_error($response)) {
8305 $error_message = $response->get_error_message();
8306 return [
8307 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
8308 'error_code' => 'openrouter_connection_error',
8309 'provider' => 'openrouter'
8310 ];
8311 }
8312
8313 $status_code = wp_remote_retrieve_response_code($response);
8314 if ($status_code !== 200) {
8315 $response_body = wp_remote_retrieve_body($response);
8316 $decoded_response = json_decode($response_body, true);
8317
8318 $error_message = isset($decoded_response['error']['message'])
8319 ? $decoded_response['error']['message']
8320 : 'HTTP Error ' . $status_code;
8321
8322 return [
8323 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
8324 'error_code' => 'openrouter_api_error',
8325 'provider' => 'openrouter',
8326 'status_code' => $status_code
8327 ];
8328 }
8329
8330 $response_body = wp_remote_retrieve_body($response);
8331 $decoded_response = json_decode($response_body, true);
8332
8333 if (isset($decoded_response['choices'][0]['message']['content'])) {
8334 return trim($decoded_response['choices'][0]['message']['content']);
8335 } else {
8336 return [
8337 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
8338 'error_code' => 'openrouter_response_format_error',
8339 'provider' => 'openrouter'
8340 ];
8341 }
8342 } catch (Exception $e) {
8343 return [
8344 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
8345 'error_code' => 'openrouter_exception',
8346 'provider' => 'openrouter'
8347 ];
8348 }
8349 }
8350 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
8351
8352 // Get bot ID from session or request
8353 $bot_id = $this->get_current_bot_id($session_id);
8354
8355 // Get system prompt instructions using centralized function
8356 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8357
8358 // Clean and validate conversation history
8359 foreach ($conversation_history as &$message) {
8360 // Convert bot and agent roles to assistant
8361 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
8362 $message['role'] = 'assistant';
8363 }
8364
8365 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
8366 if (!in_array($message['role'], ['assistant', 'user'])) {
8367 $message['role'] = 'user';
8368 }
8369
8370 // Ensure content field exists
8371 if (!isset($message['content']) || empty($message['content'])) {
8372 $message['content'] = '';
8373 }
8374
8375 // Remove any unsupported fields
8376 $message = array_intersect_key($message, array_flip(['role', 'content']));
8377 }
8378
8379 // Add relevant content as the latest user message
8380 $conversation_history[] = [
8381 'role' => 'user',
8382 'content' => $relevant_content
8383 ];
8384
8385 // Build request body
8386 $body = json_encode([
8387 'model' => $selected_model,
8388 'max_tokens' => 1000,
8389 'temperature' => 0.8,
8390 'messages' => $conversation_history,
8391 'system' => $system_prompt_instructions
8392 ]);
8393
8394 // Set up API request
8395 $args = [
8396 'body' => $body,
8397 'headers' => [
8398 'Content-Type' => 'application/json',
8399 'x-api-key' => $claude_api_key,
8400 'anthropic-version' => '2023-06-01'
8401 ],
8402 'timeout' => 60,
8403 'redirection' => 5,
8404 'blocking' => true,
8405 'httpversion' => '1.0',
8406 'sslverify' => true,
8407 ];
8408
8409 // Make API request
8410 $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
8411
8412 // Check for WordPress errors
8413 if (is_wp_error($response)) {
8414 //error_log("Claude API request error: " . $response->get_error_message());
8415 return "Sorry, there was an error connecting to the API.";
8416 }
8417
8418 // Check HTTP response code
8419 $http_code = wp_remote_retrieve_response_code($response);
8420 if ($http_code !== 200) {
8421 $error_body = wp_remote_retrieve_body($response);
8422 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
8423
8424 // Try to extract error message from response
8425 $error_data = json_decode($error_body, true);
8426 $error_message = isset($error_data['error']['message']) ?
8427 $error_data['error']['message'] :
8428 "HTTP error " . $http_code;
8429
8430 return "Sorry, the API returned an error: " . $error_message;
8431 }
8432
8433 // Parse response
8434 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8435
8436 // Check for JSON decode errors
8437 if (json_last_error() !== JSON_ERROR_NONE) {
8438 //error_log("Claude API JSON decode error: " . json_last_error_msg());
8439 return "Sorry, there was an error processing the API response.";
8440 }
8441
8442 // Extract and validate response content
8443 if (isset($response_body['content']) &&
8444 is_array($response_body['content']) &&
8445 !empty($response_body['content']) &&
8446 isset($response_body['content'][0]['text'])) {
8447 return trim($response_body['content'][0]['text']);
8448 }
8449
8450 // Log unexpected response format
8451 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
8452 return "Sorry, I received an unexpected response format from the API.";
8453 }
8454 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
8455 try {
8456 // Ensure conversation_history is an array
8457 if (!is_array($conversation_history)) {
8458 $conversation_history = array();
8459 }
8460
8461 // Get bot ID from session or request
8462 $bot_id = $this->get_current_bot_id('');
8463
8464 // Get system prompt instructions using centralized function
8465 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8466
8467 // Create a new array for the formatted conversation
8468 $formatted_conversation = array();
8469
8470 // Add system message first
8471 $formatted_conversation[] = array(
8472 'role' => 'system',
8473 'content' => $system_prompt_instructions . " " . $relevant_content
8474 );
8475
8476 // Add the rest of the conversation history
8477 foreach ($conversation_history as $message) {
8478 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8479 $role = $message['role'];
8480
8481 // Convert roles to supported format
8482 if ($role === 'bot' || $role === 'agent') {
8483 $role = 'assistant';
8484 }
8485 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8486 $role = 'user';
8487 }
8488
8489 $formatted_conversation[] = array(
8490 'role' => $role,
8491 'content' => $message['content']
8492 );
8493 }
8494 }
8495
8496 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8497 $is_gpt5_model = (
8498 strpos($selected_model, 'gpt-5') === 0 ||
8499 $selected_model === 'gpt-5.2' ||
8500 $selected_model === 'gpt-5.1-2025-11-13' ||
8501 $selected_model === 'gpt-5' ||
8502 $selected_model === 'gpt-5-mini' ||
8503 $selected_model === 'gpt-5-nano'
8504 );
8505
8506 // Build request body with optimal settings for fast responses
8507 $request_body = [
8508 'model' => $selected_model,
8509 'messages' => $formatted_conversation,
8510 'temperature' => 1,
8511 'stream' => false
8512 ];
8513
8514 // Add reasoning_effort only for GPT-5 models that support it
8515 // These chat models don't support reasoning_effort parameter
8516 $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');
8517 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
8518 // GPT-5.1 uses 'low' instead of 'minimal'
8519 if ($selected_model === 'gpt-5.1-2025-11-13') {
8520 $request_body['reasoning_effort'] = 'low';
8521 } elseif ($selected_model === 'gpt-5.4') {
8522 $request_body['reasoning_effort'] = 'none';
8523 } else {
8524 $request_body['reasoning_effort'] = 'minimal';
8525 }
8526 }
8527
8528 $body = json_encode($request_body);
8529
8530 $args = [
8531 'body' => $body,
8532 'headers' => [
8533 'Content-Type' => 'application/json',
8534 'Authorization' => 'Bearer ' . $api_key,
8535 ],
8536 'timeout' => 60,
8537 'redirection' => 5,
8538 'blocking' => true,
8539 'httpversion' => '1.0',
8540 'sslverify' => true,
8541 ];
8542
8543 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
8544
8545 if (is_wp_error($response)) {
8546 $error_message = $response->get_error_message();
8547 return [
8548 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
8549 'error_code' => 'openai_connection_error',
8550 'provider' => 'openai'
8551 ];
8552 }
8553
8554 $status_code = wp_remote_retrieve_response_code($response);
8555 if ($status_code !== 200) {
8556 $response_body = wp_remote_retrieve_body($response);
8557 $decoded_response = json_decode($response_body, true);
8558
8559 $error_message = isset($decoded_response['error']['message'])
8560 ? $decoded_response['error']['message']
8561 : 'HTTP Error ' . $status_code;
8562
8563 $error_type = isset($decoded_response['error']['type'])
8564 ? $decoded_response['error']['type']
8565 : 'unknown';
8566
8567 // Handle specific error types
8568 switch ($error_type) {
8569 case 'invalid_request_error':
8570 if (strpos($error_message, 'API key') !== false) {
8571 return [
8572 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
8573 'error_code' => 'openai_invalid_api_key',
8574 'provider' => 'openai'
8575 ];
8576 }
8577 break;
8578
8579 case 'authentication_error':
8580 return [
8581 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
8582 'error_code' => 'openai_auth_error',
8583 'provider' => 'openai'
8584 ];
8585
8586 case 'rate_limit_exceeded':
8587 return [
8588 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
8589 'error_code' => 'openai_rate_limit',
8590 'provider' => 'openai'
8591 ];
8592
8593 case 'quota_exceeded':
8594 return [
8595 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
8596 'error_code' => 'openai_quota_exceeded',
8597 'provider' => 'openai'
8598 ];
8599 }
8600
8601 // Generic error fallback
8602 return [
8603 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
8604 'error_code' => 'openai_api_error',
8605 'provider' => 'openai',
8606 'status_code' => $status_code
8607 ];
8608 }
8609
8610 $response_body = wp_remote_retrieve_body($response);
8611 $decoded_response = json_decode($response_body, true);
8612
8613 if (isset($decoded_response['choices'][0]['message']['content'])) {
8614 return trim($decoded_response['choices'][0]['message']['content']);
8615 } else {
8616 return [
8617 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
8618 'error_code' => 'openai_response_format_error',
8619 'provider' => 'openai'
8620 ];
8621 }
8622 } catch (Exception $e) {
8623 return [
8624 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
8625 'error_code' => 'openai_exception',
8626 'provider' => 'openai'
8627 ];
8628 }
8629 }
8630
8631 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
8632 try {
8633 // Get bot ID from session or request
8634 $bot_id = $this->get_current_bot_id($session_id);
8635
8636 // Get system prompt instructions using centralized function
8637 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8638
8639 // Add system prompt to relevant content
8640 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8641
8642 // Prepend system instructions to the conversation history
8643 array_unshift($conversation_history, [
8644 'role' => 'system',
8645 'content' => "Here are your instructions: " . $content_with_instructions
8646 ]);
8647
8648 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
8649 foreach ($conversation_history as &$message) {
8650 if ($message['role'] === 'bot') {
8651 $message['role'] = 'assistant';
8652 } elseif ($message['role'] === 'agent') {
8653 // Tag the message as coming from a live agent
8654 $message['role'] = 'assistant';
8655 if (!isset($message['metadata'])) {
8656 $message['metadata'] = ['source' => 'live_agent'];
8657 }
8658 }
8659
8660 // Ensure all roles are valid
8661 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
8662 $message['role'] = 'user'; // Default to 'user'
8663 }
8664 }
8665
8666 // Build the request body
8667 $body = json_encode([
8668 'model' => $selected_model,
8669 'messages' => $conversation_history,
8670 'temperature' => 0.8,
8671 'stream' => false
8672 ]);
8673
8674 // Set up the API request
8675 $args = [
8676 'body' => $body,
8677 'headers' => [
8678 'Content-Type' => 'application/json',
8679 'Authorization' => 'Bearer ' . $xai_api_key,
8680 ],
8681 'timeout' => 60,
8682 'redirection' => 5,
8683 'blocking' => true,
8684 'httpversion' => '1.0',
8685 'sslverify' => true,
8686 ];
8687
8688 // Make the API request
8689 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
8690
8691 // Process the response
8692 if (is_wp_error($response)) {
8693 $error_message = $response->get_error_message();
8694 //error_log('X.AI API Error: ' . $error_message);
8695 return [
8696 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
8697 'error_code' => 'xai_connection_error',
8698 'provider' => 'xai'
8699 ];
8700 }
8701
8702 $status_code = wp_remote_retrieve_response_code($response);
8703 if ($status_code !== 200) {
8704 $response_body = wp_remote_retrieve_body($response);
8705 $decoded_response = json_decode($response_body, true);
8706
8707 // Log the full response for debugging
8708 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
8709
8710 // Extract error message from X.AI's specific format
8711 $error_message = '';
8712
8713 // Check for direct error string (as seen in your logs)
8714 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
8715 $error_message = $decoded_response['error'];
8716 }
8717 // Check for nested error object (OpenAI style)
8718 elseif (isset($decoded_response['error']['message'])) {
8719 $error_message = $decoded_response['error']['message'];
8720 }
8721 // Check for top-level message
8722 elseif (isset($decoded_response['message'])) {
8723 $error_message = $decoded_response['message'];
8724 }
8725 // Fallback
8726 else {
8727 $error_message = 'HTTP Error ' . $status_code;
8728 }
8729
8730 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
8731
8732 // Check for API key errors using string matching
8733 if (stripos($error_message, 'api key') !== false ||
8734 stripos($error_message, 'incorrect api key') !== false ||
8735 stripos($error_message, 'invalid api key') !== false) {
8736 return [
8737 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
8738 'error_code' => 'xai_invalid_api_key',
8739 'provider' => 'xai'
8740 ];
8741 }
8742
8743 // Authentication errors
8744 if ($status_code === 401 || $status_code === 403 ||
8745 stripos($error_message, 'auth') !== false) {
8746 return [
8747 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
8748 'error_code' => 'xai_auth_error',
8749 'provider' => 'xai'
8750 ];
8751 }
8752
8753 // Model errors
8754 if (stripos($error_message, 'model') !== false) {
8755 return [
8756 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
8757 'error_code' => 'xai_invalid_model',
8758 'provider' => 'xai'
8759 ];
8760 }
8761
8762 // Rate limit errors
8763 if ($status_code === 429 ||
8764 stripos($error_message, 'rate') !== false ||
8765 stripos($error_message, 'limit') !== false) {
8766 return [
8767 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
8768 'error_code' => 'xai_rate_limit',
8769 'provider' => 'xai'
8770 ];
8771 }
8772
8773 // Quota errors
8774 if (stripos($error_message, 'quota') !== false ||
8775 stripos($error_message, 'billing') !== false) {
8776 return [
8777 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
8778 'error_code' => 'xai_quota_exceeded',
8779 'provider' => 'xai'
8780 ];
8781 }
8782
8783 // Server errors
8784 if ($status_code >= 500) {
8785 return [
8786 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
8787 'error_code' => 'xai_service_unavailable',
8788 'provider' => 'xai'
8789 ];
8790 }
8791
8792 // Generic error fallback with the actual error message
8793 return [
8794 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
8795 'error_code' => 'xai_api_error',
8796 'provider' => 'xai',
8797 'status_code' => $status_code
8798 ];
8799 }
8800
8801 $response_body = wp_remote_retrieve_body($response);
8802 $decoded_response = json_decode($response_body, true);
8803
8804 if (isset($decoded_response['choices'][0]['message']['content'])) {
8805 return trim($decoded_response['choices'][0]['message']['content']);
8806 } else {
8807 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
8808 return [
8809 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
8810 'error_code' => 'xai_response_format_error',
8811 'provider' => 'xai'
8812 ];
8813 }
8814 } catch (Exception $e) {
8815 //error_log('X.AI Exception: ' . $e->getMessage());
8816 return [
8817 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
8818 'error_code' => 'xai_exception',
8819 'provider' => 'xai'
8820 ];
8821 }
8822
8823
8824 }
8825 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
8826 try {
8827 // Ensure conversation_history is an array
8828 if (!is_array($conversation_history)) {
8829 $conversation_history = array();
8830 }
8831
8832 // Get bot ID from session or request
8833 $bot_id = $this->get_current_bot_id($session_id);
8834
8835 // Get system prompt instructions using centralized function
8836 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8837
8838 // Create a new array for the formatted conversation
8839 $formatted_conversation = array();
8840
8841 // Add system message first
8842 $formatted_conversation[] = array(
8843 'role' => 'system',
8844 'content' => $system_prompt_instructions . " " . $relevant_content
8845 );
8846
8847 // Add the rest of the conversation history
8848 foreach ($conversation_history as $message) {
8849 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8850 $role = $message['role'];
8851
8852 // Convert roles to supported format
8853 if ($role === 'bot' || $role === 'agent') {
8854 $role = 'assistant';
8855 }
8856 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8857 $role = 'user';
8858 }
8859
8860 $formatted_conversation[] = array(
8861 'role' => $role,
8862 'content' => $message['content']
8863 );
8864 }
8865 }
8866
8867 $body = json_encode([
8868 'model' => $selected_model,
8869 'messages' => $formatted_conversation,
8870 'temperature' => 0.8,
8871 'stream' => false
8872 ]);
8873
8874 $args = [
8875 'body' => $body,
8876 'headers' => [
8877 'Content-Type' => 'application/json',
8878 'Authorization' => 'Bearer ' . $deepseek_api_key,
8879 ],
8880 'timeout' => 60,
8881 'redirection' => 5,
8882 'blocking' => true,
8883 'httpversion' => '1.0',
8884 'sslverify' => true,
8885 ];
8886
8887 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
8888
8889 if (is_wp_error($response)) {
8890 $error_message = $response->get_error_message();
8891 //error_log('DeepSeek API Error: ' . $error_message);
8892 return [
8893 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
8894 'error_code' => 'deepseek_connection_error',
8895 'provider' => 'deepseek'
8896 ];
8897 }
8898
8899 $status_code = wp_remote_retrieve_response_code($response);
8900 if ($status_code !== 200) {
8901 $response_body = wp_remote_retrieve_body($response);
8902 $decoded_response = json_decode($response_body, true);
8903
8904 $error_message = isset($decoded_response['error']['message'])
8905 ? $decoded_response['error']['message']
8906 : 'HTTP Error ' . $status_code;
8907
8908 $error_type = isset($decoded_response['error']['type'])
8909 ? $decoded_response['error']['type']
8910 : 'unknown';
8911
8912 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
8913
8914 // Handle specific error types
8915 switch ($status_code) {
8916 case 401:
8917 return [
8918 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
8919 'error_code' => 'deepseek_auth_error',
8920 'provider' => 'deepseek'
8921 ];
8922
8923 case 400:
8924 if (strpos($error_message, 'API key') !== false) {
8925 return [
8926 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
8927 'error_code' => 'deepseek_invalid_api_key',
8928 'provider' => 'deepseek'
8929 ];
8930 }
8931 break;
8932
8933 case 429:
8934 if (strpos($error_message, 'quota') !== false) {
8935 return [
8936 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
8937 'error_code' => 'deepseek_quota_exceeded',
8938 'provider' => 'deepseek'
8939 ];
8940 } else {
8941 return [
8942 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
8943 'error_code' => 'deepseek_rate_limit',
8944 'provider' => 'deepseek'
8945 ];
8946 }
8947
8948 case 500:
8949 case 502:
8950 case 503:
8951 case 504:
8952 return [
8953 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
8954 'error_code' => 'deepseek_service_unavailable',
8955 'provider' => 'deepseek'
8956 ];
8957 }
8958
8959 // Generic error fallback
8960 return [
8961 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
8962 'error_code' => 'deepseek_api_error',
8963 'provider' => 'deepseek',
8964 'status_code' => $status_code
8965 ];
8966 }
8967
8968 $response_body = wp_remote_retrieve_body($response);
8969 $decoded_response = json_decode($response_body, true);
8970
8971 if (isset($decoded_response['choices'][0]['message']['content'])) {
8972 return trim($decoded_response['choices'][0]['message']['content']);
8973 } else {
8974 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
8975 return [
8976 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
8977 'error_code' => 'deepseek_response_format_error',
8978 'provider' => 'deepseek'
8979 ];
8980 }
8981 } catch (Exception $e) {
8982 //error_log('DeepSeek Exception: ' . $e->getMessage());
8983 return [
8984 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
8985 'error_code' => 'deepseek_exception',
8986 'provider' => 'deepseek'
8987 ];
8988 }
8989 }
8990 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
8991 // Get bot ID from session or request
8992 $bot_id = $this->get_current_bot_id($session_id);
8993
8994 // Get system prompt instructions using centralized function
8995 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8996
8997 // Add system prompt to relevant content
8998 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8999
9000 // Format messages for Gemini API
9001 $formatted_messages = [];
9002
9003 // Add system message as the first user message with role prefix
9004 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
9005 $formatted_messages[] = [
9006 'role' => 'user',
9007 'parts' => [
9008 ['text' => "[System Instructions] " . $content_with_instructions]
9009 ]
9010 ];
9011
9012 // Add model response to acknowledge system instructions
9013 $formatted_messages[] = [
9014 'role' => 'model',
9015 'parts' => [
9016 ['text' => "I understand and will follow these instructions."]
9017 ]
9018 ];
9019
9020 // Process the rest of the conversation history
9021 $current_role = null;
9022 $current_parts = [];
9023
9024 foreach ($conversation_history as $message) {
9025 // Skip the first system message as we already handled it
9026 if ($message['role'] === 'system') {
9027 continue;
9028 }
9029
9030 // Map roles to Gemini format
9031 $gemini_role = '';
9032 if ($message['role'] === 'user') {
9033 $gemini_role = 'user';
9034 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
9035 $gemini_role = 'model';
9036 } else {
9037 // Skip unsupported roles
9038 continue;
9039 }
9040
9041 // If we have a new role, add the previous message
9042 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
9043 $formatted_messages[] = [
9044 'role' => $current_role,
9045 'parts' => $current_parts
9046 ];
9047 $current_parts = [];
9048 }
9049
9050 // Set current role and add text to parts
9051 $current_role = $gemini_role;
9052 $current_parts[] = ['text' => $message['content']];
9053 }
9054
9055 // Add the last message if there's content
9056 if ($current_role !== null && !empty($current_parts)) {
9057 $formatted_messages[] = [
9058 'role' => $current_role,
9059 'parts' => $current_parts
9060 ];
9061 }
9062
9063 // Build the request body
9064 $body = json_encode([
9065 'contents' => $formatted_messages,
9066 'generationConfig' => [
9067 'temperature' => 0.7,
9068 'topP' => 0.95,
9069 'topK' => 40,
9070 'maxOutputTokens' => 8192,
9071 ],
9072 'safetySettings' => [
9073 [
9074 'category' => 'HARM_CATEGORY_HARASSMENT',
9075 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9076 ],
9077 [
9078 'category' => 'HARM_CATEGORY_HATE_SPEECH',
9079 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9080 ],
9081 [
9082 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
9083 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9084 ],
9085 [
9086 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
9087 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9088 ]
9089 ]
9090 ]);
9091
9092 // Prepare the API endpoint
9093 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models
9094 $api_version = (strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
9095 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
9096
9097 // Set up the API request
9098 $args = [
9099 'body' => $body,
9100 'headers' => [
9101 'Content-Type' => 'application/json',
9102 ],
9103 'timeout' => 60,
9104 'redirection' => 5,
9105 'blocking' => true,
9106 'httpversion' => '1.0',
9107 'sslverify' => true,
9108 ];
9109
9110 // Make the API request
9111 $response = wp_remote_post($api_endpoint, $args);
9112
9113 // Process the response
9114 if (is_wp_error($response)) {
9115 return "Sorry, there was an error processing your request: " . $response->get_error_message();
9116 }
9117
9118 $response_body = json_decode(wp_remote_retrieve_body($response), true);
9119
9120 // Handle potential errors in the response
9121 if (isset($response_body['error'])) {
9122 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
9123 return "Sorry, there was an error with the Gemini API: " .
9124 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
9125 }
9126
9127 // Extract the response text
9128 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
9129 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
9130 } else {
9131 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
9132 return "Sorry, I couldn't process that request. The response format was unexpected.";
9133 }
9134 }
9135
9136
9137 public function test_streaming_request() {
9138 $options = get_option('mxchat_options', []);
9139 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
9140
9141 // Detect provider from model prefix
9142 $provider = strtolower(explode('-', $model)[0]);
9143
9144 $sample_prompt = 'Hello! Can you stream this response back to me?';
9145 $messages = [['role' => 'user', 'content' => $sample_prompt]];
9146 $headers = [];
9147 $body = [];
9148 $url = '';
9149 $api_key = '';
9150
9151 switch ($provider) {
9152 case 'gpt':
9153 case 'o1':
9154 $api_key = $options['api_key'] ?? '';
9155 if (empty($api_key)) return '❌ Missing API key for OpenAI';
9156 $url = 'https://api.openai.com/v1/chat/completions';
9157 $headers = [
9158 'Content-Type: application/json',
9159 'Authorization: Bearer ' . $api_key
9160 ];
9161 $body = [
9162 'model' => $model,
9163 'messages' => $messages,
9164 'stream' => true
9165 ];
9166 break;
9167
9168 case 'claude':
9169 $api_key = $options['claude_api_key'] ?? '';
9170 if (empty($api_key)) return '❌ Missing API key for Claude';
9171 $url = 'https://api.anthropic.com/v1/messages';
9172 $headers = [
9173 'Content-Type: application/json',
9174 'x-api-key: ' . $api_key,
9175 'anthropic-version: 2023-06-01'
9176 ];
9177 $body = [
9178 'model' => $model,
9179 'messages' => $messages,
9180 'max_tokens' => 100,
9181 'stream' => true
9182 ];
9183 break;
9184
9185 case 'grok':
9186 $api_key = $options['xai_api_key'] ?? '';
9187 if (empty($api_key)) return '❌ Missing API key for X.AI';
9188 $url = 'https://api.x.ai/v1/chat/completions';
9189 $headers = [
9190 'Content-Type: application/json',
9191 'Authorization: Bearer ' . $api_key
9192 ];
9193 $body = [
9194 'model' => $model,
9195 'messages' => $messages,
9196 'stream' => true
9197 ];
9198 break;
9199
9200 case 'deepseek':
9201 if (empty($deepseek_api_key)) {
9202 $error_response = [
9203 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
9204 'error_code' => 'missing_deepseek_api_key'
9205 ];
9206 if ($testing_data !== null) {
9207 $error_response['testing_data'] = $testing_data;
9208 }
9209 return $error_response;
9210 }
9211 if ($streaming) {
9212 return $this->mxchat_generate_response_deepseek_stream(
9213 $selected_model,
9214 $deepseek_api_key,
9215 $conversation_history,
9216 $relevant_content,
9217 $session_id,
9218 $testing_data // Pass testing data
9219 );
9220 } else {
9221 $response = $this->mxchat_generate_response_deepseek(
9222 $selected_model,
9223 $deepseek_api_key,
9224 $conversation_history,
9225 $relevant_content
9226 );
9227 }
9228 break;
9229
9230 case 'gemini':
9231 $api_key = $options['gemini_api_key'] ?? '';
9232 if (empty($api_key)) return '❌ Missing API key for Gemini';
9233 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
9234 $headers = ['Content-Type: application/json'];
9235 $body = [
9236 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
9237 'generationConfig' => ['temperature' => 0.7]
9238 ];
9239 break;
9240
9241 default:
9242 return '❌ Unsupported provider: ' . $provider;
9243 }
9244
9245 // Do the actual streaming test
9246 $ch = curl_init($url);
9247 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
9248 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
9249 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
9250 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
9251 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9252
9253 $response = curl_exec($ch);
9254 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
9255 $error = curl_error($ch);
9256 curl_close($ch);
9257
9258 if ($error) return "❌ cURL error: $error";
9259 if ($http_code !== 200) {
9260 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
9261 return "❌ HTTP $http_code: $error_message";
9262 }
9263
9264 return true;
9265 }
9266
9267 public function mxchat_dismiss_pre_chat_message() {
9268 // Get and sanitize the user identifier
9269 $user_id = $this->mxchat_get_user_identifier();
9270 $user_id = sanitize_key($user_id);
9271
9272 // Set a transient to track that the user has dismissed the pre-chat message
9273 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9274 set_transient($transient_key, true, DAY_IN_SECONDS);
9275
9276 wp_send_json_success();
9277 }
9278
9279 public function mxchat_check_pre_chat_message_status() {
9280 // Get and sanitize the user identifier
9281 $user_id = $this->mxchat_get_user_identifier();
9282 $user_id = sanitize_key($user_id);
9283
9284 // Check if the transient exists (i.e., if the message was dismissed)
9285 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9286 $dismissed = get_transient($transient_key);
9287
9288 // Log the result to see if it's being set correctly
9289 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
9290
9291 if ($dismissed) {
9292 wp_send_json_success(['dismissed' => true]);
9293 } else {
9294 wp_send_json_success(['dismissed' => false]);
9295 }
9296
9297 wp_die();
9298 }
9299
9300 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
9301 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
9302 return 0;
9303 }
9304
9305 $dotProduct = array_sum(array_map(function ($a, $b) {
9306 return $a * $b;
9307 }, $vectorA, $vectorB));
9308 $normA = sqrt(array_sum(array_map(function ($a) {
9309 return $a * $a;
9310 }, $vectorA)));
9311 $normB = sqrt(array_sum(array_map(function ($b) {
9312 return $b * $b;
9313 }, $vectorB)));
9314
9315 if ($normA == 0 || $normB == 0) {
9316 return 0;
9317 }
9318
9319 return $dotProduct / ($normA * $normB);
9320 }
9321
9322
9323 public function mxchat_enqueue_scripts_styles() {
9324 // Fetch options from the database first to check loading strategy
9325 $this->options = get_option('mxchat_options');
9326 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9327
9328 // Always enqueue CSS immediately
9329 wp_enqueue_style(
9330 'mxchat-chat-css',
9331 plugin_dir_url(__FILE__) . '../css/chat-style.css',
9332 array(),
9333 MXCHAT_VERSION
9334 );
9335
9336 // Protect MxChat CSS from LiteSpeed UCSS/CCSS stripping via data-no-optimize attribute
9337 add_filter('style_loader_tag', function($tag, $handle) {
9338 if ($handle === 'mxchat-chat-css' || strpos($handle, 'mxchat') !== false) {
9339 $tag = str_replace("rel='stylesheet'", "rel='stylesheet' data-no-optimize='1'", $tag);
9340 $tag = str_replace('rel="stylesheet"', 'rel="stylesheet" data-no-optimize="1"', $tag);
9341 }
9342 return $tag;
9343 }, 10, 2);
9344
9345 // Handle script loading based on strategy
9346 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9347 // Enqueue the script normally
9348 wp_enqueue_script(
9349 'mxchat-chat-js',
9350 plugin_dir_url(__FILE__) . '../js/chat-script.js',
9351 array('jquery'),
9352 MXCHAT_VERSION,
9353 true
9354 );
9355
9356 // Add defer attribute if strategy is 'defer'
9357 if ($loading_strategy === 'defer') {
9358 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
9359 }
9360 } else {
9361 // For delay or interaction-based loading, we'll use a custom loader
9362 // Don't enqueue the main script - we'll load it dynamically
9363 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
9364 }
9365
9366 // Protect MxChat JS from LiteSpeed optimization stripping via data-no-optimize attribute
9367 add_filter('script_loader_tag', function($tag, $handle) {
9368 if ($handle === 'mxchat-chat-js' || strpos($handle, 'mxchat') !== false) {
9369 $tag = str_replace('<script ', '<script data-no-optimize="1" ', $tag);
9370 }
9371 return $tag;
9372 }, 10, 2);
9373 $prompts_options = get_option('mxchat_prompts_options', array());
9374
9375 // Check if AI theme is active - if so, skip inline colors in JavaScript
9376 $theme_options = get_option('mxchat_theme_options', array());
9377 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9378 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9379 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9380
9381 // Prepare settings for JavaScript
9382 $style_settings = array(
9383 'ajax_url' => admin_url('admin-ajax.php'),
9384 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9385 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9386 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9387 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9388 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9389 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9390 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9391 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9392 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9393 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9394 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9395 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9396 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9397 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9398 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9399 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9400 'icon_color' => $this->options['icon_color'] ?? '#fff',
9401 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9402 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9403 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9404 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9405 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9406 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9407 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9408 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9409 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9410 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9411 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
9412 'initial_email_state' => null, // Also fixed this undefined variable
9413 'skip_email_check' => true,
9414 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9415 'skip_inline_colors' => $skip_inline_colors,
9416 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9417 );
9418
9419 // For normal/defer loading, use wp_localize_script
9420 // For delayed loading, we store settings in a transient to be output inline
9421 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9422 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
9423 } else {
9424 // Store settings for the delayed loader to use
9425 set_transient('mxchat_delayed_settings_' . get_current_user_id(), $style_settings, 60);
9426 }
9427 }
9428
9429 /**
9430 * Output the delayed script loader for performance optimization
9431 */
9432 public function mxchat_output_delayed_script_loader() {
9433 $this->options = get_option('mxchat_options');
9434 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9435 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
9436
9437 // Get the stored settings
9438 $prompts_options = get_option('mxchat_prompts_options', array());
9439 $theme_options = get_option('mxchat_theme_options', array());
9440 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9441 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9442 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9443
9444 $style_settings = array(
9445 'ajax_url' => admin_url('admin-ajax.php'),
9446 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9447 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9448 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9449 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9450 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9451 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9452 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9453 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9454 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9455 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9456 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9457 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9458 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9459 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9460 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9461 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9462 'icon_color' => $this->options['icon_color'] ?? '#fff',
9463 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9464 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9465 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9466 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9467 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9468 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9469 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9470 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9471 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9472 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9473 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
9474 'initial_email_state' => null,
9475 'skip_email_check' => true,
9476 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9477 'skip_inline_colors' => $skip_inline_colors,
9478 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9479 );
9480
9481 // Determine delay time based on strategy
9482 $delay_ms = 0;
9483 switch ($loading_strategy) {
9484 case 'delay_1s':
9485 $delay_ms = 1000;
9486 break;
9487 case 'delay_3s':
9488 $delay_ms = 3000;
9489 break;
9490 case 'delay_5s':
9491 $delay_ms = 5000;
9492 break;
9493 }
9494
9495 ?>
9496 <script type="text/javascript">
9497 (function() {
9498 var mxchatLoaded = false;
9499 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
9500 window.mxchatChat = mxchatChat;
9501
9502 function loadMxChatScript() {
9503 if (mxchatLoaded) return;
9504 mxchatLoaded = true;
9505
9506 function appendChatScript() {
9507 var script = document.createElement('script');
9508 script.src = <?php echo wp_json_encode($script_url); ?>;
9509 script.type = 'text/javascript';
9510 document.body.appendChild(script);
9511 }
9512
9513 if (typeof jQuery !== 'undefined') {
9514 appendChatScript();
9515 } else {
9516 var jq = document.createElement('script');
9517 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
9518 jq.onload = appendChatScript;
9519 document.body.appendChild(jq);
9520 }
9521 }
9522
9523 <?php if ($loading_strategy === 'on_interaction'): ?>
9524 // Load on user interaction
9525 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
9526 events.forEach(function(evt) {
9527 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
9528 });
9529 // Fallback: load after 8 seconds if no interaction
9530 setTimeout(loadMxChatScript, 8000);
9531 <?php else: ?>
9532 // Load after specified delay
9533 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
9534 <?php endif; ?>
9535 })();
9536 </script>
9537 <?php
9538 }
9539
9540 /**
9541 * Setup the cron jobs for rate limits with guard against multiple calls
9542 */
9543 public function setup_rate_limit_cron_jobs() {
9544 // Add a guard to prevent multiple rapid calls
9545 $last_setup = get_transient('mxchat_cron_setup_guard');
9546 if ($last_setup && (time() - $last_setup) < 60) {
9547 // Don't run again if we ran less than 60 seconds ago
9548 return;
9549 }
9550
9551 // Set the guard
9552 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
9553
9554 try {
9555 // First, check if WordPress cron is disabled
9556 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
9557 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
9558 $this->setup_fallback_rate_limit_system();
9559 return;
9560 }
9561
9562 // Check if cron is already scheduled - if so, don't mess with it
9563 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
9564 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
9565 return;
9566 }
9567
9568 // Clear any orphaned hooks (but don't loop indefinitely)
9569 $hooks_to_clear = [
9570 'mxchat_reset_rate_limits',
9571 'mxchat_reset_hourly_rate_limits',
9572 'mxchat_reset_daily_rate_limits',
9573 'mxchat_reset_weekly_rate_limits',
9574 'mxchat_reset_monthly_rate_limits'
9575 ];
9576
9577 foreach ($hooks_to_clear as $hook) {
9578 // Only clear a maximum of 3 instances to prevent infinite loops
9579 $cleared = 0;
9580 while (wp_next_scheduled($hook) && $cleared < 3) {
9581 wp_clear_scheduled_hook($hook);
9582 $cleared++;
9583 }
9584 }
9585
9586 // Small delay after clearing
9587 usleep(100000); // 0.1 seconds
9588
9589 // Try to schedule the event
9590 $initial_time = time() + 300; // Start in 5 minutes
9591 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
9592
9593 if ($result === false) {
9594 //error_log('MxChat: Failed to schedule cron, using fallback system');
9595 $this->setup_fallback_rate_limit_system();
9596 } else {
9597 //error_log('MxChat: Successfully scheduled rate limit reset cron');
9598 }
9599
9600 } catch (Exception $e) {
9601 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
9602 $this->setup_fallback_rate_limit_system();
9603 }
9604 }
9605
9606 /**
9607 * Try alternative cron scheduling methods
9608 */
9609 private function try_alternative_cron_scheduling($initial_time) {
9610 try {
9611 // Method 1: Try with current time instead of future time
9612 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
9613 if ($result1 !== false) {
9614 //error_log('MxChat: Alternative method 1 (current time) succeeded');
9615 return true;
9616 }
9617
9618 // Method 2: Try with a different interval
9619 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
9620 if ($result2 !== false) {
9621 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
9622 return true;
9623 }
9624
9625 // Method 3: Try wp_schedule_single_event first, then recurring
9626 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
9627 if ($result3 !== false) {
9628 //error_log('MxChat: Alternative method 3 (single event) succeeded');
9629 // Schedule the next one manually in the handler
9630 return true;
9631 }
9632
9633 return false;
9634
9635 } catch (Exception $e) {
9636 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
9637 return false;
9638 }
9639 }
9640
9641 /**
9642 * Enhanced fallback rate limit system
9643 */
9644 private function setup_fallback_rate_limit_system() {
9645 // Set a flag to use database-based rate limit cleanup
9646 update_option('mxchat_use_fallback_rate_limits', true);
9647
9648 // Schedule a one-time check to happen on the next plugin load
9649 update_option('mxchat_next_rate_limit_check', time() + 3600);
9650
9651 // Also set up a more frequent fallback check (every 4 hours)
9652 update_option('mxchat_fallback_check_interval', 4 * 3600);
9653
9654 //error_log('MxChat: Fallback rate limit system activated');
9655 }
9656
9657 /**
9658 * Enhanced fallback check method
9659 */
9660 public function check_fallback_rate_limits() {
9661 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9662
9663 if (!$use_fallback) {
9664 return; // Regular cron is working
9665 }
9666
9667 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9668 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
9669
9670 if (time() >= $next_check) {
9671 //error_log('MxChat: Running fallback rate limit cleanup');
9672 $this->mxchat_reset_rate_limits();
9673
9674 // Schedule next check
9675 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
9676 }
9677 }
9678 /**
9679 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
9680 */
9681 public function check_rate_limit() {
9682 // Check if we need to run fallback cleanup
9683 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9684 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9685
9686 if ($use_fallback && time() >= $next_check) {
9687 $this->mxchat_reset_rate_limits();
9688 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
9689 }
9690
9691 // Get bot ID from current request context
9692 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9693
9694 // Get bot-specific options (includes rate limits if overridden)
9695 $bot_options = $this->get_bot_options($bot_id);
9696 $current_options = !empty($bot_options) ? $bot_options : $this->options;
9697
9698 // Use bot-specific rate limits if available, otherwise fall back to default
9699 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
9700
9701 // Determine user role or if logged out
9702 if (is_user_logged_in()) {
9703 $user = wp_get_current_user();
9704 $user_id = $user->ID;
9705
9706 // Get the user's primary role using reset() to safely get the first element
9707 $user_roles = $user->roles;
9708
9709 // Safely get the first role regardless of array key structure
9710 if (!empty($user_roles) && is_array($user_roles)) {
9711 $role = reset($user_roles); // This safely gets the first element regardless of key
9712 } else {
9713 $role = 'subscriber'; // Default to subscriber if no role found
9714 }
9715 } else {
9716 $role = 'logged_out';
9717 // Use IP address for non-logged-in users
9718 $user_id = $this->get_client_ip();
9719 }
9720
9721 // Check if rate limits are configured for this role
9722 if (!isset($rate_limits_source[$role])) {
9723 return true; // No limit set for this role
9724 }
9725
9726 $limit = $rate_limits_source[$role]['limit'];
9727
9728 // If unlimited, return true immediately
9729 if ($limit === 'unlimited') {
9730 return true;
9731 }
9732
9733 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
9734 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
9735 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
9736 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
9737
9738 // Include bot_id in option name so each bot has separate rate limits
9739 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
9740
9741 // Get the counter data
9742 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
9743
9744 // If first request or counter reset needed, set the initial timestamp
9745 if ($limit_data['count'] === 0) {
9746 $limit_data['timestamp'] = time();
9747 update_option($option_name, $limit_data);
9748 }
9749
9750 // Get the timeframe
9751 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
9752 $rate_limits_source[$role]['timeframe'] : 'daily';
9753
9754 // Check if the counter needs to be reset based on timeframe
9755 $current_time = time();
9756 $timestamp = $limit_data['timestamp'];
9757 $should_reset = false;
9758
9759 switch ($timeframe) {
9760 case 'hourly':
9761 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
9762 break;
9763 case 'daily':
9764 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
9765 break;
9766 case 'weekly':
9767 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
9768 break;
9769 case 'monthly':
9770 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
9771 break;
9772 }
9773
9774 // Reset the counter if the timeframe has passed
9775 if ($should_reset) {
9776 $limit_data = ['count' => 0, 'timestamp' => $current_time];
9777 update_option($option_name, $limit_data);
9778 }
9779
9780 // Check if user has exceeded their limit
9781 if ($limit_data['count'] >= intval($limit)) {
9782 // Get the custom message for this role
9783 $message = !empty($rate_limits_source[$role]['message'])
9784 ? $rate_limits_source[$role]['message']
9785 : __('Rate limit exceeded. Please try again later.', 'mxchat');
9786
9787 // Add timeframe information to the message if placeholders exist
9788 $timeframe_label = '';
9789 switch ($timeframe) {
9790 case 'hourly':
9791 $timeframe_label = __('hour', 'mxchat');
9792 break;
9793 case 'daily':
9794 $timeframe_label = __('day', 'mxchat');
9795 break;
9796 case 'weekly':
9797 $timeframe_label = __('week', 'mxchat');
9798 break;
9799 case 'monthly':
9800 $timeframe_label = __('month', 'mxchat');
9801 break;
9802 }
9803
9804 // Replace placeholders in the message
9805 $message = str_replace(
9806 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
9807 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
9808 $message
9809 );
9810
9811 // Process HTML links in the message
9812 $message = $this->process_rate_limit_message_html($message);
9813
9814 // Return error with the processed message
9815 return [
9816 'error' => true,
9817 'message' => $message
9818 ];
9819 }
9820
9821 // Increment the counter
9822 $limit_data['count']++;
9823 update_option($option_name, $limit_data);
9824
9825 return true;
9826 }
9827
9828 /**
9829 * Enhanced rate limit reset with better error handling
9830 */
9831 public function mxchat_reset_rate_limits() {
9832 try {
9833 global $wpdb;
9834 $all_options = get_option('mxchat_options', []);
9835 $current_time = time();
9836
9837 // Get rate limit options with a safer query and limit
9838 $option_names = $wpdb->get_col(
9839 $wpdb->prepare(
9840 "SELECT option_name FROM {$wpdb->options}
9841 WHERE option_name LIKE %s
9842 LIMIT 1000",
9843 'mxchat_chat_limit_%'
9844 )
9845 );
9846
9847 if (empty($option_names)) {
9848 return;
9849 }
9850
9851 $processed_count = 0;
9852 $max_processing_time = 30; // Maximum 30 seconds
9853 $start_time = time();
9854
9855 foreach ($option_names as $option_name) {
9856 // Check processing time limit
9857 if ((time() - $start_time) > $max_processing_time) {
9858 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
9859 break;
9860 }
9861
9862 // Parse the option name more safely
9863 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
9864 continue;
9865 }
9866
9867 $role_and_user = $matches[1] . '_' . $matches[2];
9868 $parts = explode('_', $role_and_user);
9869
9870 if (count($parts) < 2) {
9871 continue;
9872 }
9873
9874 // Extract role (everything except the last part which is user ID)
9875 $user_id_part = array_pop($parts);
9876 $role = implode('_', $parts);
9877
9878 // Skip if role doesn't exist in our settings
9879 if (!isset($all_options['rate_limits'][$role])) {
9880 // Clean up orphaned entries
9881 delete_option($option_name);
9882 continue;
9883 }
9884
9885 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
9886 $limit_data = get_option($option_name);
9887
9888 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
9889 // Clean up invalid entries
9890 delete_option($option_name);
9891 continue;
9892 }
9893
9894 $timestamp = $limit_data['timestamp'];
9895 $should_reset = false;
9896
9897 // Determine if we should reset based on the timeframe
9898 switch ($timeframe) {
9899 case 'hourly':
9900 $should_reset = ($current_time - $timestamp) >= 3600;
9901 break;
9902 case 'daily':
9903 $should_reset = ($current_time - $timestamp) >= 86400;
9904 break;
9905 case 'weekly':
9906 $should_reset = ($current_time - $timestamp) >= 604800;
9907 break;
9908 case 'monthly':
9909 $should_reset = ($current_time - $timestamp) >= 2592000;
9910 break;
9911 }
9912
9913 // Reset the counter if the timeframe has passed
9914 if ($should_reset) {
9915 delete_option($option_name);
9916 wp_cache_delete($option_name, 'options');
9917 $processed_count++;
9918 }
9919 }
9920
9921 // Clean up any orphaned cache entries
9922 wp_cache_delete('mxchat_all_chat_limits', 'options');
9923
9924 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
9925
9926 } catch (Exception $e) {
9927 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
9928 }
9929 }
9930
9931
9932 /**
9933 * Process HTML links in rate limit messages
9934 *
9935 * @param string $message The rate limit message
9936 * @return string The processed message with safe HTML links
9937 */
9938 private function process_rate_limit_message_html($message) {
9939 // Return original message if empty
9940 if (empty($message)) {
9941 return $message;
9942 }
9943
9944 // First, convert markdown links to HTML
9945 $message = $this->convert_markdown_links($message);
9946
9947 // Then, auto-convert any remaining plain URLs to links
9948 $message = $this->auto_link_urls($message);
9949
9950 // Allow basic HTML tags for links and formatting
9951 $allowed_tags = [
9952 'a' => [
9953 'href' => true,
9954 'target' => true,
9955 'rel' => true,
9956 'title' => true,
9957 'class' => true
9958 ],
9959 'strong' => [],
9960 'em' => [],
9961 'br' => [],
9962 'b' => [],
9963 'i' => [],
9964 'span' => ['class' => true]
9965 ];
9966
9967 // Sanitize but allow the specified HTML tags
9968 $processed_message = wp_kses($message, $allowed_tags);
9969
9970 // If wp_kses stripped everything, return the original message as plain text
9971 if (empty($processed_message) && !empty($message)) {
9972 // Strip all HTML and return plain text as fallback
9973 return wp_strip_all_tags($message);
9974 }
9975
9976 return $processed_message;
9977 }
9978
9979 /**
9980 * Convert markdown links to HTML
9981 *
9982 * @param string $text The text to process
9983 * @return string The text with markdown links converted to HTML
9984 */
9985 private function convert_markdown_links($text) {
9986 // Return original text if empty
9987 if (empty($text)) {
9988 return $text;
9989 }
9990
9991 // Pattern to match markdown links: [text](url)
9992 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
9993
9994 $processed_text = preg_replace_callback($pattern, function($matches) {
9995 $link_text = $matches[1];
9996 $url = $matches[2];
9997
9998 // Clean up any trailing punctuation from the URL
9999 $url = rtrim($url, '.,;:!?');
10000
10001 // Sanitize the link text and URL
10002 $safe_text = esc_html($link_text);
10003 $safe_url = esc_url($url);
10004
10005 // Create the HTML link
10006 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
10007 }, $text);
10008
10009 // If preg_replace_callback failed, return original text
10010 if ($processed_text === null) {
10011 return $text;
10012 }
10013
10014 return $processed_text;
10015 }
10016
10017 /**
10018 * Auto-convert plain URLs to clickable links
10019 *
10020 * @param string $text The text to process
10021 * @return string The text with URLs converted to links
10022 */
10023 private function auto_link_urls($text) {
10024 // Return original text if empty
10025 if (empty($text)) {
10026 return $text;
10027 }
10028
10029 // Simple pattern that avoids complex lookbehinds
10030 // This will match URLs that are not already inside href attributes or markdown links
10031 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
10032
10033 $processed_text = preg_replace_callback($pattern, function($matches) {
10034 $url = $matches[0];
10035 // Clean up any trailing punctuation that might have been captured
10036 $url = rtrim($url, '.,;:!?');
10037
10038 // Add target="_blank" and rel="noopener noreferrer" for security
10039 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
10040 }, $text);
10041
10042 // If preg_replace_callback failed, return original text
10043 if ($processed_text === null) {
10044 return $text;
10045 }
10046
10047 return $processed_text;
10048 }
10049
10050
10051 // Helper function to get client IP address
10052 private function get_client_ip() {
10053 // Check for shared internet/ISP IP
10054 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
10055 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
10056 }
10057
10058 // Check for IPs passing through proxies
10059 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
10060 // Use the first value in the comma-separated list
10061 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
10062 return trim($forwarded_for[0]);
10063 }
10064
10065 if (!empty($_SERVER['REMOTE_ADDR'])) {
10066 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
10067 }
10068
10069 // Fallback
10070 return 'unknown';
10071 }
10072
10073 /**
10074 * AJAX handler to get system information for testing panel
10075 */
10076 /**
10077 * AJAX handler to get system information for testing panel
10078 */
10079 public function mxchat_get_system_info() {
10080 // Verify nonce for security
10081 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10082 wp_send_json_error(['message' => 'Invalid nonce']);
10083 return;
10084 }
10085
10086 // Only allow admin users
10087 if (!current_user_can('administrator')) {
10088 wp_send_json_error(['message' => 'Unauthorized']);
10089 return;
10090 }
10091
10092 // Get system prompt from options
10093 $system_prompt = isset($this->options['system_prompt_instructions'])
10094 ? $this->options['system_prompt_instructions']
10095 : 'No system prompt configured';
10096
10097 // Get selected model
10098 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
10099
10100 // Check if OpenRouter is being used
10101 $is_openrouter = ($selected_model === 'openrouter');
10102 $openrouter_model = '';
10103
10104 if ($is_openrouter) {
10105 // Get the actual OpenRouter model that's selected
10106 $openrouter_model = isset($this->options['openrouter_selected_model'])
10107 ? $this->options['openrouter_selected_model']
10108 : 'No OpenRouter model selected';
10109
10110 // Update selected_model display to show both
10111 $selected_model = 'OpenRouter: ' . $openrouter_model;
10112 }
10113
10114 // Get API key status (just check if they exist, don't expose the keys)
10115 $api_status = [];
10116 $api_status['openai'] = !empty($this->options['api_key']);
10117 $api_status['claude'] = !empty($this->options['claude_api_key']);
10118 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
10119 $api_status['xai'] = !empty($this->options['xai_api_key']);
10120 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
10121 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
10122
10123 wp_send_json_success([
10124 'system_prompt' => $system_prompt,
10125 'selected_model' => $selected_model,
10126 'is_openrouter' => $is_openrouter,
10127 'openrouter_model' => $openrouter_model,
10128 'api_status' => $api_status
10129 ]);
10130 }
10131
10132 /**
10133 * AJAX handler to get similarity threshold
10134 */
10135 public function mxchat_get_similarity_threshold() {
10136 // Verify nonce for security
10137 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10138 wp_send_json_error(['message' => 'Invalid nonce']);
10139 return;
10140 }
10141
10142 // Only allow admin users
10143 if (!current_user_can('administrator')) {
10144 wp_send_json_error(['message' => 'Unauthorized']);
10145 return;
10146 }
10147
10148 // Get similarity threshold from main options (default 35%)
10149 $similarity_threshold = isset($this->options['similarity_threshold'])
10150 ? ((int) $this->options['similarity_threshold']) / 100
10151 : 0.35;
10152
10153 wp_send_json_success([
10154 'threshold' => $similarity_threshold,
10155 'threshold_percentage' => ($similarity_threshold * 100) . '%'
10156 ]);
10157 }
10158
10159 /**
10160 * AJAX handler to get knowledge base status
10161 */
10162 public function mxchat_get_kb_status() {
10163 // Verify nonce for security
10164 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10165 wp_send_json_error(['message' => 'Invalid nonce']);
10166 return;
10167 }
10168
10169 // Only allow admin users
10170 if (!current_user_can('administrator')) {
10171 wp_send_json_error(['message' => 'Unauthorized']);
10172 return;
10173 }
10174
10175 // Check OpenAI Vector Store first (takes priority)
10176 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
10177 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
10178
10179 if ($use_vectorstore) {
10180 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
10181 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
10182
10183 $kb_info = [
10184 'type' => 'OpenAI Vector Store',
10185 'status' => 'Active',
10186 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
10187 ];
10188
10189 wp_send_json_success($kb_info);
10190 return;
10191 }
10192
10193 // Check Pinecone vs WordPress
10194 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10195 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10196
10197 $kb_info = [
10198 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
10199 'status' => 'Active'
10200 ];
10201
10202 // Get document count
10203 if ($use_pinecone) {
10204 $kb_info['documents'] = 'Connected to Pinecone';
10205 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
10206 } else {
10207 // Count documents in WordPress database
10208 global $wpdb;
10209 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10210 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
10211 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
10212 }
10213
10214 wp_send_json_success($kb_info);
10215 }
10216
10217 /**
10218 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
10219 */
10220 public function mxchat_start_fresh_session() {
10221 // Verify nonce for security
10222 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10223 wp_send_json_error(['message' => 'Invalid nonce']);
10224 return;
10225 }
10226
10227 // Only allow admin users
10228 if (!current_user_can('administrator')) {
10229 wp_send_json_error(['message' => 'Unauthorized']);
10230 return;
10231 }
10232
10233 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
10234 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
10235
10236 if (empty($old_session_id)) {
10237 wp_send_json_error(['message' => 'Old session ID required']);
10238 return;
10239 }
10240
10241 // If no new session ID provided, generate one
10242 if (empty($new_session_id)) {
10243 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
10244 }
10245
10246 // Clear ALL data associated with the old session
10247 $this->clear_complete_session_data($old_session_id);
10248
10249 // Initialize the new session
10250 $this->initialize_fresh_session($new_session_id);
10251
10252 wp_send_json_success([
10253 'message' => 'Fresh session started successfully',
10254 'new_session_id' => $new_session_id,
10255 'old_session_id' => $old_session_id
10256 ]);
10257 }
10258
10259 /**
10260 * Clear ALL data associated with a session (ENHANCED)
10261 */
10262 private function clear_complete_session_data($session_id) {
10263 // Clear chat history
10264 delete_option("mxchat_history_{$session_id}");
10265
10266 // Clear chat mode
10267 delete_option("mxchat_mode_{$session_id}");
10268
10269 // Clear any PDF/Word transients
10270 $this->clear_pdf_transients($session_id);
10271 if (method_exists($this, 'clear_word_transients')) {
10272 $this->clear_word_transients($session_id);
10273 }
10274
10275 // Clear agent-related data
10276 delete_option("mxchat_channel_{$session_id}");
10277 delete_option("mxchat_agent_name_{$session_id}");
10278 delete_option("mxchat_email_{$session_id}");
10279
10280 // Clear any recommendation flow state
10281 delete_option("mxchat_sr_flow_state_{$session_id}");
10282
10283 // Clear any cached embeddings or context
10284 delete_transient("mxchat_context_{$session_id}");
10285 delete_transient("mxchat_last_query_{$session_id}");
10286
10287 // Clear any testing data
10288 delete_transient("mxchat_testing_data_{$session_id}");
10289
10290 // Clear any rate limiting data for this session
10291 delete_transient("mxchat_rate_limit_{$session_id}");
10292
10293 // Clear any other session-specific transients
10294 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
10295 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
10296 delete_transient("mxchat_include_word_in_context_{$session_id}");
10297
10298 // Clear form addon state (pending forms and submitted forms)
10299 delete_option("mxchat_pending_form_{$session_id}");
10300 delete_option("mxchat_submitted_forms_{$session_id}");
10301
10302 //error_log("MxChat: Cleared all data for session: {$session_id}");
10303 }
10304
10305 /**
10306 * Initialize a fresh session with default data
10307 */
10308 private function initialize_fresh_session($session_id) {
10309 // Set default chat mode
10310 update_option("mxchat_mode_{$session_id}", 'ai');
10311
10312 //error_log("MxChat: Initialized fresh session: {$session_id}");
10313 }
10314
10315 /**
10316 * Helper method to clear Word document transients (if you have Word support)
10317 */
10318 private function clear_word_transients($session_id) {
10319 delete_transient('mxchat_word_url_' . $session_id);
10320 delete_transient('mxchat_word_filename_' . $session_id);
10321 delete_transient('mxchat_word_embeddings_' . $session_id);
10322 delete_transient('mxchat_include_word_in_context_' . $session_id);
10323 }
10324
10325 /**
10326 * Simplified testing data capture method (CLEANED UP)
10327 */
10328 private function capture_testing_data($user_embedding, $message, $session_id) {
10329 // Only capture for admin users
10330 if (!current_user_can('administrator')) {
10331 return null;
10332 }
10333
10334 $testing_data = [
10335 'query' => $message,
10336 'timestamp' => time(),
10337 'top_matches' => [],
10338 'action_matches' => [] // Add action matches
10339 ];
10340
10341 // Get similarity threshold
10342 $similarity_threshold = isset($this->options['similarity_threshold'])
10343 ? ((int) $this->options['similarity_threshold']) / 100
10344 : 0.35;
10345
10346 $testing_data['similarity_threshold'] = $similarity_threshold;
10347
10348 // Use the real similarity analysis if available
10349 if ($this->last_similarity_analysis !== null) {
10350 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
10351 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
10352 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10353 } else {
10354 // Fallback: determine knowledge base type
10355 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10356 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10357
10358 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
10359 }
10360
10361 // Include action analysis if available
10362 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
10363 $testing_data['action_matches'] = $this->last_action_analysis;
10364
10365 // Clear it after capturing to avoid stale data
10366 $this->last_action_analysis = null;
10367 }
10368
10369 return $testing_data;
10370 }
10371
10372
10373 /**
10374 * Track URL clicks from chatbot responses
10375 */
10376 public function mxchat_track_url_click() {
10377 // Verify nonce for security
10378 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10379 wp_send_json_error(['message' => 'Invalid nonce']);
10380 wp_die();
10381 }
10382
10383 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10384 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
10385 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
10386
10387 if (empty($session_id) || empty($clicked_url)) {
10388 wp_send_json_error(['message' => 'Missing required data']);
10389 wp_die();
10390 }
10391
10392 global $wpdb;
10393 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10394
10395 // Insert click tracking record
10396 $wpdb->insert(
10397 $table_name,
10398 [
10399 'session_id' => $session_id,
10400 'clicked_url' => $clicked_url,
10401 'message_context' => $message_context,
10402 'click_timestamp' => current_time('mysql', 1),
10403 'user_ip' => $_SERVER['REMOTE_ADDR'],
10404 'user_agent' => $_SERVER['HTTP_USER_AGENT']
10405 ]
10406 );
10407
10408 wp_send_json_success(['message' => 'Click tracked']);
10409 wp_die();
10410 }
10411
10412 /**
10413 * Get URL click analytics for a session
10414 */
10415 public function mxchat_get_url_clicks($session_id) {
10416 global $wpdb;
10417 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10418
10419 $clicks = $wpdb->get_results($wpdb->prepare(
10420 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
10421 $session_id
10422 ));
10423
10424 return $clicks;
10425 }
10426 /**
10427 * Track the originating page where chat was started
10428 */
10429 public function mxchat_track_originating_page() {
10430 // Verify nonce
10431 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10432 wp_send_json_error(['message' => 'Invalid nonce']);
10433 wp_die();
10434 }
10435
10436 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10437 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
10438 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
10439
10440 if (empty($session_id)) {
10441 wp_send_json_error(['message' => 'Missing session ID']);
10442 wp_die();
10443 }
10444
10445 global $wpdb;
10446 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
10447
10448 // Check if we've already tracked for this session
10449 $existing = $wpdb->get_var($wpdb->prepare(
10450 "SELECT COUNT(*) FROM $table_name
10451 WHERE session_id = %s
10452 AND originating_page_url IS NOT NULL",
10453 $session_id
10454 ));
10455
10456 if ($existing > 0) {
10457 wp_send_json_success(['message' => 'Already tracked']);
10458 wp_die();
10459 }
10460
10461 // Update the first message in this session with originating page info
10462 $wpdb->query($wpdb->prepare(
10463 "UPDATE $table_name
10464 SET originating_page_url = %s,
10465 originating_page_title = %s
10466 WHERE session_id = %s
10467 ORDER BY timestamp ASC
10468 LIMIT 1",
10469 $page_url,
10470 $page_title,
10471 $session_id
10472 ));
10473
10474 wp_send_json_success(['message' => 'Originating page tracked']);
10475 wp_die();
10476 }
10477
10478 /**
10479 * Validate and clean URLs from AI response
10480 * Removes any URLs that aren't in the knowledge base
10481 *
10482 * @param string $response_text The AI-generated response
10483 * @param array $valid_urls Array of URLs from the knowledge base
10484 * @return string Cleaned response with invalid URLs removed/flagged
10485 */
10486 private function validate_and_clean_urls($response_text, $valid_urls) {
10487 // DEBUG: Log what we're working with
10488 //error_log("=== MxChat URL Validation Debug ===");
10489 //error_log("Valid URLs count: " . count($valid_urls));
10490 //error_log("Valid URLs: " . print_r($valid_urls, true));
10491 //error_log("Response text length: " . strlen($response_text));
10492 //error_log("Response text preview: " . substr($response_text, 0, 500));
10493
10494 // If no valid URLs provided or empty response, return as-is
10495 if (empty($valid_urls) || empty($response_text)) {
10496 //error_log("Validation skipped - empty valid_urls or response");
10497 return $response_text;
10498 }
10499
10500 // Extract all URLs from the AI response
10501 // This regex matches http:// and https:// URLs
10502 preg_match_all(
10503 '#\bhttps?://[^\s<>"\')\]]+#i',
10504 $response_text,
10505 $matches
10506 );
10507
10508 // If no URLs found in response, return as-is
10509 if (empty($matches[0])) {
10510 //error_log("No URLs found in response");
10511 return $response_text;
10512 }
10513
10514 $found_urls = $matches[0];
10515 $cleaned_response = $response_text;
10516 $removed_count = 0;
10517
10518 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
10519 $normalized_valid_urls = array_map(function($url) {
10520 // Remove trailing slash
10521 $url = rtrim($url, '/');
10522 // Remove URL fragments (#section)
10523 $url = preg_replace('/#.*$/', '', $url);
10524 // Remove trailing punctuation that might have been captured
10525 $url = rtrim($url, '.,;:!?');
10526 return $url;
10527 }, $valid_urls);
10528
10529 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
10530
10531 foreach ($found_urls as $found_url) {
10532 // Clean up the found URL (remove trailing punctuation that might have been captured)
10533 $clean_found_url = rtrim($found_url, '.,;:!?)');
10534
10535 // DEBUG: Log each URL being checked
10536 //error_log("Checking found URL: " . $found_url);
10537
10538 // Normalize for comparison
10539 $normalized_found = rtrim($clean_found_url, '/');
10540 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
10541
10542 //error_log("Normalized found URL: " . $normalized_found);
10543
10544 // Check if this URL exists in our valid URLs list
10545 $is_valid = false;
10546
10547 //error_log("Starting validation checks for: " . $normalized_found);
10548
10549 // First, try exact match
10550 if (in_array($normalized_found, $normalized_valid_urls)) {
10551 $is_valid = true;
10552 //error_log("EXACT MATCH FOUND");
10553 } else {
10554 //error_log("No exact match, checking variations...");
10555 // If no exact match, check if it's a variation (with query params, etc.)
10556 foreach ($normalized_valid_urls as $valid_url) {
10557 //error_log(" Comparing against valid URL: " . $valid_url);
10558
10559 // Check if the found URL starts with a valid URL (handles query params)
10560 if (strpos($normalized_found, $valid_url) === 0) {
10561 // Check what comes after the valid URL
10562 $remainder = substr($normalized_found, strlen($valid_url));
10563
10564 // Only valid if:
10565 // 1. Exact match (remainder is empty)
10566 // 2. Query params (starts with ?)
10567 // 3. Fragment (starts with #)
10568 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
10569 $is_valid = true;
10570 //error_log(" MATCH: Found URL is valid variation of base URL");
10571 break;
10572 } else {
10573 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
10574 }
10575 }
10576 // Also check the reverse (in case valid URL has query params)
10577 if (strpos($valid_url, $normalized_found) === 0) {
10578 $is_valid = true;
10579 //error_log(" MATCH: Valid URL starts with found URL");
10580 break;
10581 }
10582 }
10583
10584 if (!$is_valid) {
10585 //error_log("NO MATCH FOUND - URL should be removed");
10586 }
10587 }
10588
10589 // If URL is not valid, remove it from the response
10590 if (!$is_valid) {
10591 // Log the removal for debugging
10592 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
10593 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
10594
10595 $removed_count++;
10596
10597 // Check if URL is part of a markdown link: [text](url)
10598 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
10599 if (preg_match($markdown_pattern, $cleaned_response)) {
10600 //error_log("Found markdown link, removing but keeping text");
10601 // Remove the markdown link but keep the text
10602 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
10603 }
10604 // Check if URL is part of an HTML link: <a href="url">text</a>
10605 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
10606 //error_log("Found HTML link, removing but keeping text");
10607 // Remove the HTML link but keep the text
10608 $link_text = $link_match[1];
10609 $cleaned_response = preg_replace(
10610 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
10611 $link_text,
10612 $cleaned_response
10613 );
10614 }
10615 // Otherwise just remove the bare URL
10616 else {
10617 //error_log("Removing bare URL");
10618 $cleaned_response = str_replace($found_url, '', $cleaned_response);
10619 }
10620 }
10621 }
10622
10623 // Log summary if any URLs were removed
10624 if ($removed_count > 0) {
10625 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
10626 } else {
10627 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
10628 }
10629
10630 // Clean up any double spaces or awkward punctuation left behind
10631 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
10632 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
10633 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
10634
10635 //error_log("Final cleaned response: " . $cleaned_response);
10636
10637 return trim($cleaned_response);
10638 }
10639
10640 /**
10641 * AJAX handler to get current chat mode for a session
10642 */
10643 public function mxchat_get_current_chat_mode() {
10644 // Verify nonce for security
10645 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10646 wp_send_json_error(['message' => 'Invalid nonce']);
10647 wp_die();
10648 }
10649
10650 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10651
10652 if (empty($session_id)) {
10653 wp_send_json_error(['message' => 'Session ID missing']);
10654 wp_die();
10655 }
10656
10657 // Get the current chat mode for this session
10658 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
10659
10660 wp_send_json_success([
10661 'chat_mode' => $chat_mode
10662 ]);
10663 wp_die();
10664 }
10665
10666
10667
10668 }
10669 ?>
10670