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

10,618 lines 432.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 class MxChat_Integrator {
7 private $options;
8 private $prompts_options;
9 private $chat_count;
10 private $fallbackResponse;
11 private $productCardHtml;
12 private $word_handler;
13 private $last_similarity_analysis = null;
14 private $current_valid_urls = [];
15 private $is_streaming = false; // ADDED: Track if current request is streaming
16 private $streaming_headers_sent = false; // Track if streaming headers have been sent
17
18 /**
19 * Setup streaming headers - call this right before actually streaming
20 * This delays header setup to allow actions/forms to return JSON responses
21 */
22 private function setup_streaming_headers() {
23 if ($this->streaming_headers_sent || headers_sent()) {
24 return false;
25 }
26
27 // Disable output buffering
28 while (ob_get_level()) {
29 ob_end_flush();
30 }
31
32 // Set headers for SSE
33 header('Content-Type: text/event-stream');
34 header('Cache-Control: no-cache');
35 header('Connection: keep-alive');
36 header('X-Accel-Buffering: no');
37
38 ob_implicit_flush(true);
39 flush();
40
41 $this->streaming_headers_sent = true;
42 return true;
43 }
44
45 /**
46 * Class constructor
47 */
48 public function __construct() {
49 $this->options = get_option('mxchat_options');
50 $this->prompts_options = get_option('mxchat_prompts_options', array());
51 $this->chat_count = get_option('mxchat_chat_count', 0);
52 $this->word_handler = new MXChat_Word_Handler($this->options);
53
54 // Add all action hooks
55 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
56 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
57 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
58 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
59 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
60
61 // Add the AJAX actions for checking if the pre-chat message was dismissed
62 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
63 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
64 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
65 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
66 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
67 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
68
69 // Add REST API routes registration
70 add_action('rest_api_init', array($this, 'register_routes'));
71 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
72 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
73
74 // Rate limit action - notice we removed the old schedule setup
75 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
76
77 // File upload and handling actions
78 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
79 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
80 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
81 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
82
83 // Word document handling actions
84 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
85 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
86 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
87 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
88 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
89 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
90
91 // Email handling actions
92 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
93 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
94 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
95 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
96
97 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
98 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
99
100 // Testing panel AJAX actions
101 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
102 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
103 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
104 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
105 // Add to your existing constructor, in the section with other AJAX actions:
106 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
107 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
108 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
109 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
110 // Add chat mode checking actions
111 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
112 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
113
114 // Nonce refresh for page-cache compatibility (WP Rocket, LiteSpeed, etc.)
115 add_action('wp_ajax_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
116 add_action('wp_ajax_nopriv_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
117
118 // Auto-email transcript action
119 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
120
121 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
122
123
124 }
125
126 /**
127 * Return a fresh nonce so cached pages can replace the stale one.
128 */
129 public function mxchat_refresh_nonce() {
130 wp_send_json_success(array('nonce' => wp_create_nonce('mxchat_chat_nonce')));
131 }
132
133 // In your core plugin's check_actions_for_addons method:
134 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
135 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
136
137 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
138
139 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
140
141 return $result;
142 }
143
144 private function mxchat_increment_chat_count() {
145 $chat_count = get_option('mxchat_chat_count', 0);
146 $chat_count++;
147 update_option('mxchat_chat_count', $chat_count);
148 }
149
150 function mxchat_fetch_conversation_history() {
151 if (empty($_POST['session_id'])) {
152 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
153 wp_die();
154 }
155
156 $session_id = sanitize_text_field($_POST['session_id']);
157
158 // SECURITY FIX: Verify session ownership before retrieving data
159 // If IP/user changed, signal frontend to reset session instead of blocking
160 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
161
162 // Check if this session has an owner recorded
163 $session_owner = get_option("mxchat_session_owner_{$session_id}");
164
165 // If session has an owner and it doesn't match current user, trigger session reset
166 if ($session_owner && $session_owner !== $current_user_identifier) {
167 wp_send_json_error([
168 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
169 'code' => 'session_expired',
170 'action' => 'reset_session'
171 ]);
172 wp_die();
173 }
174
175 // If no owner is set yet, claim ownership (for legacy sessions)
176 if (!$session_owner) {
177 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
178 }
179
180 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
181 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
182
183 if (empty($history)) {
184 // Even if history is empty, return the chat mode
185 wp_send_json_success([
186 'conversation' => [],
187 'chat_mode' => $chat_mode
188 ]);
189 wp_die();
190 }
191
192 wp_send_json_success([
193 'conversation' => $history,
194 'chat_mode' => $chat_mode
195 ]);
196 wp_die();
197 }
198 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
199 $history = get_option("mxchat_history_{$session_id}", []);
200
201 // Check persistence setting - when OFF, only include messages from current page load
202 $options = get_option('mxchat_options', []);
203 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
204
205 // Filter history when persistence is OFF to match what the user sees
206 if (!$persistence_enabled && $session_start_timestamp > 0) {
207 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
208 // Include messages from this page load onwards
209 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
210 });
211 // Re-index array after filtering
212 $history = array_values($history);
213 }
214
215 $formatted_history = [];
216
217 // Adjusted for code-heavy conversations
218 $max_tokens = 120000; // Context window size
219 $reserved_tokens = 5000; // Space for system prompts + current query
220 $current_token_count = 0;
221
222 // Allowed HTML tags for content sanitization
223 $allowed_tags = [
224 'pre' => ['class' => true],
225 'code' => ['class' => true],
226 'span' => ['class' => true],
227 'div' => ['class' => true],
228 'strong' => [],
229 'em' => []
230 ];
231
232 foreach (array_reverse($history) as $entry) {
233 // Preserve code blocks while sanitizing other HTML
234 $clean_content = wp_kses($entry['content'], $allowed_tags);
235
236 // Detect code blocks in content
237 $has_code = false;
238 // Replace the HTML check with:
239 // Allow messages that contain code blocks or are plain text
240 if (strpos($clean_content, '<pre') === false &&
241 strpos($clean_content, '<code') === false &&
242 $clean_content !== strip_tags($entry['content'])) {
243 continue;
244 }
245
246 // Skip entries that lost significant content during sanitization
247 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
248 continue;
249 }
250
251 // More accurate token estimation (1 token ≈ 4 characters)
252 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
253
254 // Check token budget with the new estimate
255 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
256 // Try to fit partial content if it's the first entry
257 if (empty($formatted_history)) {
258 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
259 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
260 } else {
261 break;
262 }
263 }
264
265 // Add to formatted history
266 $formatted_history[] = [
267 'role' => $entry['role'],
268 'content' => $clean_content
269 ];
270
271 $current_token_count += $token_estimate;
272 }
273
274 // Reverse back to maintain chronological order
275 $formatted_history = array_reverse($formatted_history);
276
277 // Add system message about code context
278 array_unshift($formatted_history, [
279 'role' => 'system',
280 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
281 . 'Maintain formatting and syntax highlighting when referencing code.'
282 ]);
283
284 return $formatted_history;
285 }
286
287 public function register_routes() {
288 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
289
290 register_rest_route('mxchat/v1', '/stream', [
291 'methods' => 'GET',
292 'callback' => [$this, 'mxchat_stream_events'],
293 'permission_callback' => [$this, 'verify_chat_session'],
294 ]);
295
296 register_rest_route('mxchat/v1', '/agent-response', [
297 'methods' => 'POST',
298 'callback' => [$this, 'mxchat_handle_agent_response'],
299 'permission_callback' => [$this, 'verify_slack_request'],
300 ]);
301
302 register_rest_route('mxchat/v1', '/slack-interaction', [
303 'methods' => 'POST',
304 'callback' => [$this, 'handle_slack_interaction'],
305 'permission_callback' => [$this, 'verify_slack_request'],
306 ]);
307
308 register_rest_route('mxchat/v1', '/slack-messages', [
309 'methods' => 'POST',
310 'callback' => [$this, 'handle_slack_messages'],
311 'permission_callback' => [$this, 'verify_slack_request'],
312 ]);
313
314 // Telegram webhook endpoint
315 register_rest_route('mxchat/v1', '/telegram-webhook', [
316 'methods' => 'POST',
317 'callback' => [$this, 'handle_telegram_webhook'],
318 'permission_callback' => [$this, 'verify_telegram_request'],
319 ]);
320
321 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
322 }
323
324 /**
325 * Verify valid chat session
326 */
327 public function verify_chat_session($request) {
328 $session_id = $request->get_param('session_id');
329 if (empty($session_id)) {
330 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
331 return false;
332 }
333
334 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
335 return $chat_mode === 'agent';
336 }
337
338 /**
339 * Verify request is coming from Slack.
340 *
341 * @param WP_REST_Request $request
342 * @return bool True if valid, false otherwise.
343 */
344 public function verify_slack_request($request) {
345 // Get the Slack signing secret from your plugin options
346 $valid_key = $this->options['live_agent_secret_key'] ?? '';
347
348 if (empty($valid_key)) {
349 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
350 return false;
351 }
352
353 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
354 $slack_signature = $request->get_header('X-Slack-Signature');
355
356 // Verify timestamp to prevent replay attacks
357 if (abs(time() - intval($timestamp)) > 300) {
358 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
359 return false;
360 }
361
362 // Get raw request body from the WP_REST_Request object
363 // (php://input may already be consumed by WordPress at this point)
364 $request_body = $request->get_body();
365
366 // Create the signature base string
367 $sig_basestring = "v0:{$timestamp}:{$request_body}";
368
369 // Calculate expected signature
370 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
371
372 // Compare signatures
373 return hash_equals($my_signature, $slack_signature);
374 }
375
376 /**
377 * Verify request is coming from Telegram.
378 *
379 * @param WP_REST_Request $request
380 * @return bool True if valid, false otherwise.
381 */
382 public function verify_telegram_request($request) {
383 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
384
385 error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
386 error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
387
388 if (empty($secret_token)) {
389 // If no secret is configured, allow the request (for initial setup)
390 error_log('[MxChat Telegram DEBUG] No secret configured, allowing request');
391 return true;
392 }
393
394 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
395 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
396
397 error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
398
399 if (empty($request_token)) {
400 error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
401 return false;
402 }
403
404 // Timing-safe comparison
405 $result = hash_equals($secret_token, $request_token);
406 error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
407 return $result;
408 }
409
410 public function mxchat_stream_events(WP_REST_Request $request) {
411 header('Content-Type: text/event-stream');
412 header('Cache-Control: no-cache');
413 header('Connection: keep-alive');
414
415 $session_id = sanitize_text_field($request->get_param('session_id'));
416 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
417
418 if (empty($session_id)) {
419 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
420 flush();
421 exit;
422 }
423
424 $history = get_option("mxchat_history_{$session_id}", []);
425
426 // Filter only new messages
427 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
428 return !empty($message['id']) && $message['id'] > $last_seen_id;
429 });
430
431 // Send new messages if available
432 if (!empty($new_messages)) {
433 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
434 } else {
435 // Keep the connection alive
436 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
437 }
438 flush();
439 exit;
440 }
441
442
443
444
445 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
446 global $wpdb;
447 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
448 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
449
450 // Check if this is the first message in a new session (before any other database operations)
451 $is_new_session = false;
452 if ($role === 'user') { // Only check for user messages, not bot responses
453 $existing_messages = $wpdb->get_var($wpdb->prepare(
454 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
455 $session_id
456 ));
457 $is_new_session = ($existing_messages == 0);
458
459 // Log for debugging
460 if ($is_new_session) {
461 //error_log("[DEBUG] This is a NEW session - first message");
462 }
463 }
464
465 // SECURITY FIX: Set session ownership for new sessions
466 if ($is_new_session && $role === 'user') {
467 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
468 $session_owner_key = "mxchat_session_owner_{$session_id}";
469
470 // Only set ownership if not already set
471 if (!get_option($session_owner_key)) {
472 update_option($session_owner_key, $current_user_identifier, 'no');
473 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
474 }
475 }
476
477 // 1) Extract agent name if present
478 $agent_name = '';
479 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
480 $agent_name = $matches[1];
481 $message = str_replace("Agent: $agent_name - ", '', $message);
482 $session_meta_key = "mxchat_agent_name_{$session_id}";
483 if (empty(get_option($session_meta_key))) {
484 update_option($session_meta_key, $agent_name);
485 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
486 }
487 }
488
489 // 2) Generate unique message_id
490 $message_id = uniqid();
491 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
492
493 // 3) Determine user_id
494 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
495
496 // 4) Determine user_identifier
497 $user_identifier = $agent_name
498 ? $agent_name
499 : MxChat_User::mxchat_get_user_identifier();
500
501 // 5) Determine displayed_name
502 $user_email = MxChat_User::mxchat_get_user_email();
503 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
504
505 // 6) Check for a saved email in wp_options
506 $email_option_key = "mxchat_email_{$session_id}";
507 $saved_email = get_option($email_option_key);
508 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
509
510 // Check for a saved name in wp_options
511 $name_option_key = "mxchat_name_{$session_id}";
512 $saved_name = get_option($name_option_key);
513 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
514
515 // If found, update DB user_email and user_name
516 if ($saved_email || $saved_name) {
517 $update_data = [];
518 if ($saved_email) {
519 $update_data['user_email'] = $saved_email;
520 }
521 if ($saved_name) {
522 $update_data['user_name'] = $saved_name;
523 }
524
525 if (!empty($update_data)) {
526 $update_res = $wpdb->update(
527 $table_name,
528 $update_data,
529 ['session_id' => $session_id],
530 array_fill(0, count($update_data), '%s'),
531 ['%s']
532 );
533 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
534 }
535 }
536
537 // 7) Save to session history in wp_options
538 $history_key = "mxchat_history_{$session_id}";
539 $history = get_option($history_key, []);
540 $history[] = [
541 'id' => $message_id,
542 'role' => $role,
543 'content' => $message,
544 'timestamp' => round(microtime(true) * 1000),
545 'agent_name' => $displayed_name,
546 ];
547 update_option($history_key, $history, 'no');
548 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
549
550 // 8) Save the message to DB (INSERT)
551 $insert_data = [
552 'user_id' => $user_id,
553 'user_identifier'=> $user_identifier,
554 'user_email' => $saved_email ?: $user_email,
555 'user_name' => $saved_name ?: '', // Add name to insert data
556 'session_id' => $session_id,
557 'role' => $role,
558 'message' => $message,
559 'timestamp' => current_time('mysql', 1),
560 ];
561
562 // IMPROVED: Handle originating page data
563 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
564
565 if ($columns_exist) {
566 if ($is_new_session && $role === 'user') {
567 // For the first user message, set originating page data
568
569 // First check if we have it from the parameter
570 if ($originating_page && !empty($originating_page['url'])) {
571 $insert_data['originating_page_url'] = $originating_page['url'];
572 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
573
574 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
575 }
576 // Otherwise check if it's stored in the instance property
577 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
578 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
579 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
580
581 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
582
583 // Clear after using
584 unset($this->pending_originating_page);
585 }
586 // Fallback to HTTP_REFERER if nothing else is available
587 else if (isset($_SERVER['HTTP_REFERER'])) {
588 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
589 $insert_data['originating_page_url'] = $referer_url;
590
591 // Generate title from URL
592 $parsed_url = parse_url($referer_url);
593 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
594
595 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
596 $insert_data['originating_page_title'] = 'Homepage';
597 } else {
598 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
599 $insert_data['originating_page_title'] = ucwords(trim($title));
600 }
601
602 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
603 }
604
605 // Store for this session so all messages have the same originating page
606 if (!empty($insert_data['originating_page_url'])) {
607 update_option("mxchat_originating_page_{$session_id}", [
608 'url' => $insert_data['originating_page_url'],
609 'title' => $insert_data['originating_page_title']
610 ], 'no');
611 }
612 } else {
613 // For subsequent messages in the session, use the stored originating page
614 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
615 if ($stored_originating && !empty($stored_originating['url'])) {
616 $insert_data['originating_page_url'] = $stored_originating['url'];
617 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
618 }
619 }
620 }
621
622 // Add RAG context if provided (for bot messages)
623 if ($rag_context !== null && $role === 'bot') {
624 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
625 if ($rag_context_column_exists) {
626 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
627 }
628 }
629
630 $wpdb->insert($table_name, $insert_data);
631 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
632
633 // 9) Send notification email if this is the first user message in a new session
634 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
635 $this->send_new_chat_notification($session_id, array(
636 'identifier' => $user_identifier,
637 'email' => $saved_email ?: $user_email,
638 'ip' => $_SERVER['REMOTE_ADDR']
639 ));
640 }
641
642 // 10) Schedule delayed transcript email if enabled and message is from user
643 if ($wpdb->insert_id && $role === 'user') {
644 $this->schedule_delayed_transcript_email($session_id);
645 }
646
647 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
648 return $message_id;
649 }
650
651 private function send_new_chat_notification($session_id, $user_info = array()) {
652 $options = get_option('mxchat_transcripts_options');
653
654 // Check if notifications are enabled
655 if (empty($options['mxchat_enable_notifications'])) {
656 return false;
657 }
658
659 // Get notification email
660 $to = !empty($options['mxchat_notification_email']) ?
661 $options['mxchat_notification_email'] :
662 get_option('admin_email');
663
664 if (!is_email($to)) {
665 return false;
666 }
667
668 // Prepare email content
669 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
670
671 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
672 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
673 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
674
675 $message = sprintf(
676 "A new chat session has started on your website.\n\n" .
677 "Session ID: %s\n" .
678 "User: %s\n" .
679 "Email: %s\n" .
680 "IP Address: %s\n" .
681 "Time: %s\n\n" .
682 "View transcripts: %s",
683 $session_id,
684 $user_identifier,
685 $user_email,
686 $user_ip,
687 current_time('mysql'),
688 admin_url('admin.php?page=mxchat-transcripts')
689 );
690
691 // Send email
692 return wp_mail($to, $subject, $message);
693 }
694
695 /**
696 * Schedule delayed transcript email for a session
697 * Reschedules if a new user message is received
698 */
699 private function schedule_delayed_transcript_email($session_id) {
700 $options = get_option('mxchat_transcripts_options');
701
702 // Check if auto-email is enabled
703 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
704 return;
705 }
706
707 // Get notification email
708 $email = !empty($options['mxchat_notification_email']) ?
709 $options['mxchat_notification_email'] :
710 get_option('admin_email');
711
712 if (!is_email($email)) {
713 return;
714 }
715
716 // Get delay in minutes (default 30)
717 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
718 intval($options['mxchat_auto_email_transcript_delay']) : 30;
719
720 // Clear any existing scheduled event for this session
721 $hook = 'mxchat_send_delayed_transcript';
722 $args = array($session_id);
723 $timestamp = wp_next_scheduled($hook, $args);
724
725 if ($timestamp) {
726 wp_unschedule_event($timestamp, $hook, $args);
727 }
728
729 // Schedule new event
730 $schedule_time = time() + ($delay_minutes * 60);
731 wp_schedule_single_event($schedule_time, $hook, $args);
732 }
733
734 /**
735 * Check if chat messages contain contact information (email or phone number)
736 *
737 * @param array $messages Array of message objects with 'message' property
738 * @param object|null $session_data Session data object with user_email property
739 * @return bool True if contact info found, false otherwise
740 */
741 private function chat_contains_contact_info($messages, $session_data = null) {
742 // Check if session already has a stored email
743 if ($session_data && !empty($session_data->user_email)) {
744 return true;
745 }
746
747 // Email regex pattern
748 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
749
750 // Phone number patterns (covers various formats including international, WhatsApp style)
751 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
752 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
753
754 // Only check user messages (not assistant responses)
755 foreach ($messages as $msg) {
756 if ($msg->role !== 'user') {
757 continue;
758 }
759
760 $message_text = $msg->message;
761
762 // Check for email
763 if (preg_match($email_pattern, $message_text)) {
764 return true;
765 }
766
767 // Check for phone number (must be at least 7 digits total to avoid false positives)
768 if (preg_match($phone_pattern, $message_text, $matches)) {
769 // Count actual digits to avoid matching short numbers
770 $digits_only = preg_replace('/\D/', '', $matches[0]);
771 if (strlen($digits_only) >= 7) {
772 return true;
773 }
774 }
775 }
776
777 return false;
778 }
779
780 /**
781 * Send the delayed transcript email with .txt attachment
782 */
783 public function mxchat_send_delayed_transcript($session_id) {
784 global $wpdb;
785
786 $options = get_option('mxchat_transcripts_options');
787
788 // Get notification email
789 $to = !empty($options['mxchat_notification_email']) ?
790 $options['mxchat_notification_email'] :
791 get_option('admin_email');
792
793 if (!is_email($to)) {
794 return false;
795 }
796
797 // Get all messages for this session
798 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
799 $messages = $wpdb->get_results($wpdb->prepare(
800 "SELECT role, message, timestamp FROM {$table_name}
801 WHERE session_id = %s
802 ORDER BY timestamp ASC",
803 $session_id
804 ));
805
806 if (empty($messages)) {
807 return false;
808 }
809
810 // Get session metadata
811 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
812 $session_data = $wpdb->get_row($wpdb->prepare(
813 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
814 $session_id
815 ));
816
817 // Check if contact info is required and if it's present
818 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
819 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
820 // Contact info required but not found - skip sending
821 return false;
822 }
823
824 // Build transcript content
825 $transcript_content = "Chat Transcript\n";
826 $transcript_content .= "================\n\n";
827 $transcript_content .= "Session ID: " . $session_id . "\n";
828
829 if ($session_data) {
830 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
831 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
832 $transcript_content .= "Started: " . $session_data->created_at . "\n";
833 }
834
835 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
836
837 // Add messages
838 foreach ($messages as $msg) {
839 $role_label = ($msg->role === 'user') ? 'User' : 'Assistant';
840 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
841 $transcript_content .= $msg->message . "\n\n";
842 }
843
844 // Create temporary file for attachment using WP_Filesystem
845 $upload_dir = wp_upload_dir();
846 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
847 global $wp_filesystem;
848 if (empty($wp_filesystem)) {
849 require_once ABSPATH . 'wp-admin/includes/file.php';
850 WP_Filesystem();
851 }
852 $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
853
854 // Prepare email
855 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
856
857 $message = "Please find attached the full chat transcript.\n\n";
858 $message .= "Session ID: {$session_id}\n";
859
860 if ($session_data) {
861 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
862 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
863 }
864
865 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
866
867 // Send email with attachment
868 $attachments = array($temp_file);
869 $result = wp_mail($to, $subject, $message, '', $attachments);
870
871 // Clean up temporary file
872 if (file_exists($temp_file)) {
873 unlink($temp_file);
874 }
875
876 return $result;
877 }
878
879
880
881 public function mxchat_handle_save_email_and_response() {
882 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
883 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
884
885 // Validate nonce
886 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
887 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
888 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
889 wp_die();
890 }
891
892 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
893 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
894 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
895
896 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
897
898 if (empty($session_id) || empty($email)) {
899 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
900 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
901 wp_die();
902 }
903
904 // Validate name if provided (check if name field is enabled and name is required)
905 $options = get_option('mxchat_options', []);
906 $name_field_enabled = isset($options['enable_name_field']) &&
907 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
908
909 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
910 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
911 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
912 wp_die();
913 }
914
915 // 1) Always store email in wp_options
916 $email_option_key = "mxchat_email_{$session_id}";
917 update_option($email_option_key, $email);
918 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
919
920 // Store name in wp_options if provided
921 if (!empty($name)) {
922 $name_option_key = "mxchat_name_{$session_id}";
923 update_option($name_option_key, $name);
924 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
925 }
926
927 // 2) (Optional) Also store in DB if a row already exists
928 global $wpdb;
929 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
930
931 // Make sure we have a valid placeholder in prepare
932 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
933 $session_count = $wpdb->get_var($sql);
934
935 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
936
937 if ($session_count) {
938 // Update both user_email and user_name if row(s) exist
939 if (!empty($name)) {
940 $update_sql = $wpdb->prepare(
941 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
942 $email,
943 $name,
944 $session_id
945 );
946 } else {
947 $update_sql = $wpdb->prepare(
948 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
949 $email,
950 $session_id
951 );
952 }
953 $wpdb->query($update_sql);
954 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
955 } else {
956 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
957 }
958
959 // Provide success response (same as original)
960 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
961 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
962 wp_send_json_success(['message' => $bot_message]);
963 wp_die();
964 }
965
966 public function mxchat_check_email_provided() {
967 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
968
969 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
970 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
971 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
972 }
973
974 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
975 if (empty($session_id)) {
976 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
977 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
978 }
979
980 // Check if the user is logged in
981 if (is_user_logged_in()) {
982 $current_user = wp_get_current_user();
983 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
984
985 // Get user's display name for logged in users
986 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
987 (!empty($current_user->first_name) ? $current_user->first_name : '');
988
989 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
990 if (!empty($user_name)) {
991 $response_data['name'] = $user_name;
992 }
993
994 wp_send_json_success($response_data);
995 }
996
997 // Check if name field is required
998 $options = get_option('mxchat_options', []);
999 $name_field_enabled = isset($options['enable_name_field']) &&
1000 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1001
1002 $email_option_key = "mxchat_email_{$session_id}";
1003 $stored_email = get_option($email_option_key, '');
1004
1005 // Check for stored name
1006 $name_option_key = "mxchat_name_{$session_id}";
1007 $stored_name = get_option($name_option_key, '');
1008
1009 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1010 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1011
1012 // Check if we have email and name (if name is required)
1013 $has_required_info = !empty($stored_email);
1014
1015 if ($name_field_enabled) {
1016 $has_required_info = $has_required_info && !empty($stored_name);
1017 }
1018
1019 if ($has_required_info) {
1020 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1021
1022 $response_data = ['email' => $stored_email];
1023 if (!empty($stored_name)) {
1024 $response_data['name'] = $stored_name;
1025 }
1026
1027 wp_send_json_success($response_data);
1028 } else {
1029 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1030 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1031 }
1032 }
1033
1034 /**
1035 * Send error response in appropriate format based on streaming mode
1036 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1037 *
1038 * @param string $error_message The error message to display
1039 * @param string $error_code Optional error code for debugging
1040 */
1041 private function send_error_response($error_message, $error_code = 'api_error') {
1042 if ($this->is_streaming) {
1043 echo "data: " . json_encode([
1044 'error' => true,
1045 'error_message' => $error_message,
1046 'error_code' => $error_code,
1047 'text' => $error_message,
1048 'message' => $error_message
1049 ]) . "\n\n";
1050 echo "data: [DONE]\n\n";
1051 flush();
1052 } else {
1053 wp_send_json_error([
1054 'error_message' => $error_message,
1055 'error_code' => $error_code
1056 ]);
1057 }
1058 wp_die();
1059 }
1060
1061 public function mxchat_handle_chat_request() {
1062 global $wpdb;
1063
1064 // Debug: Log incoming bot_id
1065 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1066 error_log("=== MXCHAT DEBUG: Starting chat request ===");
1067 error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1068
1069 // Get bot-specific options
1070 $bot_options = $this->get_bot_options($bot_id);
1071 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1072
1073 // Check if this is a streaming request
1074 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1075 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1076 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1077 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1078
1079 // ADDED: Store streaming state in class property for use in private methods
1080 $this->is_streaming = $is_streaming;
1081
1082 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1083 // This allows actions/forms to return JSON responses without header conflicts
1084
1085 // Check if MX Chat Moderation is active
1086 if (class_exists('MX_Chat_Moderation')) {
1087 // Get user email and IP
1088 $user_email = '';
1089 $user_ip = $_SERVER['REMOTE_ADDR'];
1090
1091 // If user is logged in, get their email
1092 if (is_user_logged_in()) {
1093 $current_user = wp_get_current_user();
1094 $user_email = $current_user->user_email;
1095 }
1096
1097 // Create ban handler instance
1098 $ban_handler = new MX_Chat_Ban_Handler();
1099
1100 // Check if user is banned by IP
1101 if ($ban_handler->check_ban($user_ip, 'ip')) {
1102 wp_send_json([
1103 'success' => false,
1104 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1105 'status' => 'banned'
1106 ]);
1107 wp_die();
1108 }
1109
1110 // If user is logged in, also check email
1111 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1112 wp_send_json([
1113 'success' => false,
1114 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1115 'status' => 'banned'
1116 ]);
1117 wp_die();
1118 }
1119 }
1120
1121 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1122 $this->productCardHtml = '';
1123
1124 // Get the actual WordPress user ID if logged in
1125 $is_logged_in = is_user_logged_in();
1126 if ($is_logged_in) {
1127 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1128 } else {
1129 // For logged-out users, use your existing identifier method
1130 $user_id = $this->mxchat_get_user_identifier();
1131 }
1132
1133 // Get and sanitize the user identifier
1134 $user_id = sanitize_key($user_id);
1135
1136 // Check rate limit using new settings structure
1137 $rate_limit_result = $this->check_rate_limit();
1138
1139 if ($rate_limit_result !== true) {
1140 wp_send_json([
1141 'success' => false,
1142 'message' => $rate_limit_result['message'],
1143 'status' => 'rate_limit_exceeded'
1144 ]);
1145 wp_die();
1146 }
1147
1148 // Rest of your existing code...
1149 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1150
1151 if (empty($session_id)) {
1152 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1153 wp_die();
1154 }
1155
1156 // SECURITY FIX: Verify session ownership before processing chat request
1157 // If IP/user changed, signal frontend to reset session instead of blocking
1158 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1159 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1160
1161 if ($session_owner && $session_owner !== $current_user_identifier) {
1162 // Instead of blocking, tell frontend to start a fresh session
1163 wp_send_json_error([
1164 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
1165 'code' => 'session_expired',
1166 'action' => 'reset_session'
1167 ]);
1168 wp_die();
1169 }
1170
1171 // Validate and sanitize the incoming message
1172 if (empty($_POST['message'])) {
1173 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1174 wp_die();
1175 }
1176
1177
1178 // Track originating page for first message in session
1179 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1180
1181 // Check if originating page columns exist
1182 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1183
1184 if ($columns_exist) {
1185 // Check if this session already has messages
1186 $message_count = $wpdb->get_var($wpdb->prepare(
1187 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1188 $session_id
1189 ));
1190
1191 // If this is the first message in the session
1192 if ($message_count == 0) {
1193 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1194 $originating_url = '';
1195 $originating_title = '';
1196
1197 // Try to get from POST data first (sent by JavaScript)
1198 if (isset($_POST['current_page_url'])) {
1199 $originating_url = esc_url_raw($_POST['current_page_url']);
1200 $originating_title = isset($_POST['current_page_title'])
1201 ? sanitize_text_field($_POST['current_page_title'])
1202 : '';
1203 }
1204 // Fallback to HTTP_REFERER if not provided by JavaScript
1205 else if (isset($_SERVER['HTTP_REFERER'])) {
1206 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1207 }
1208
1209 // Generate title if we have URL but no title
1210 if ($originating_url && empty($originating_title)) {
1211 $parsed_url = parse_url($originating_url);
1212 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1213
1214 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1215 $originating_title = 'Homepage';
1216 } else {
1217 // Clean up the path to make a readable title
1218 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1219 $originating_title = ucwords(trim($originating_title));
1220 }
1221 }
1222
1223 // Store for later use when saving the message
1224 $this->pending_originating_page = [
1225 'url' => $originating_url,
1226 'title' => $originating_title
1227 ];
1228 }
1229 }
1230
1231
1232
1233 // Get page context if provided
1234 $page_context = null;
1235 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1236 $page_context_raw = stripslashes($_POST['page_context']);
1237 $page_context = json_decode($page_context_raw, true);
1238
1239 // Validate page context structure
1240 if (is_array($page_context) &&
1241 isset($page_context['url']) &&
1242 isset($page_context['title']) &&
1243 isset($page_context['content'])) {
1244
1245 // Sanitize page context
1246 $page_context['url'] = esc_url_raw($page_context['url']);
1247 $page_context['title'] = sanitize_text_field($page_context['title']);
1248 $page_context['content'] = wp_kses_post($page_context['content']);
1249 } else {
1250 $page_context = null;
1251 }
1252 }
1253
1254 // Modify the message sanitization to preserve PHP tags in code blocks
1255 $allowed_tags = [
1256 'pre' => [],
1257 'code' => ['class' => true],
1258 'span' => ['class' => true],
1259 'div' => ['class' => true],
1260 ];
1261
1262 // First preserve code blocks
1263 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1264 return htmlspecialchars_decode($matches[0]);
1265 }, $_POST['message']);
1266
1267 // Then apply sanitization
1268 $message = wp_kses($message, $allowed_tags);
1269
1270 // Preserve code blocks from markdown conversion
1271 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1272 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1273
1274 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1275 // Always initialize testing data for admins (no toggle needed)
1276 $testing_data = null;
1277 if (current_user_can('administrator')) {
1278 // For vision messages, use the original user message for the query display
1279 $query_for_testing = $message;
1280 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1281 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1282 }
1283
1284 $testing_data = [
1285 'query' => $query_for_testing,
1286 'timestamp' => time(),
1287 'top_matches' => [],
1288 'action_matches' => [], // Initialize action matches array
1289 'page_context' => $page_context, // Include page context in testing data
1290 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1291 'bot_id' => $bot_id // Include bot ID in testing data
1292 ];
1293
1294 // Get similarity threshold from bot options or default options
1295 $similarity_threshold = isset($current_options['similarity_threshold'])
1296 ? ((int) $current_options['similarity_threshold']) / 100
1297 : 0.35;
1298
1299 $testing_data['similarity_threshold'] = $similarity_threshold;
1300
1301 // Determine knowledge base type using bot-specific config
1302 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1303 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1304 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1305 }
1306 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1307
1308 // Add debug before and after:
1309 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1310 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1311 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1312
1313
1314 // If the pre-processing returned a result (not the original message), use it directly
1315 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1316 // Save the AI response
1317 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1318
1319 // Save HTML content if provided
1320 if (!empty($pre_processed_result['html'])) {
1321 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1322 }
1323
1324 // Add testing data if admin
1325 $response_data = [
1326 'text' => $pre_processed_result['text'],
1327 'html' => $pre_processed_result['html'] ?? '',
1328 'session_id' => $session_id
1329 ];
1330
1331 if ($testing_data !== null) {
1332 $response_data['testing_data'] = $testing_data;
1333 }
1334
1335 wp_send_json($response_data);
1336 wp_die();
1337 }
1338
1339 // Save the user's message - handle vision processed messages differently
1340 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1341 // For vision messages, save the original user message with image indicator
1342 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1343 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1344 $image_count = intval($_POST['vision_images_count']);
1345 $original_message .= " [{$image_count} image(s)]";
1346 }
1347 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1348 } else {
1349 // Regular message - save as normal
1350 $this->mxchat_save_chat_message($session_id, 'user', $message);
1351 }
1352
1353
1354 if (is_email($message)) {
1355 // Add the email to Loops
1356 $this->add_email_to_loops($message);
1357
1358 // Get the user's success message instruction using current_options
1359 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1360
1361 // Set instruction for AI using the user's success message
1362 $this->current_action_instruction = $user_success_message;
1363
1364 // Clear the email capture transient since we got the email
1365 delete_transient('mxchat_email_capture_' . $user_id);
1366 }
1367
1368 // Check if we're in an email capture flow but user hasn't provided email yet
1369 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1370 // Check if the message contains an email (not the whole message being an email)
1371 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1372 $extracted_email = $matches[0];
1373
1374 // Add the extracted email to Loops
1375 $this->add_email_to_loops($extracted_email);
1376
1377 // Get the user's success message instruction using current_options
1378 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1379
1380 // Set instruction for AI using the user's success message
1381 $this->current_action_instruction = $user_success_message;
1382
1383 // Clear the email capture transient since we got the email
1384 delete_transient('mxchat_email_capture_' . $user_id);
1385 }
1386 // If no email found but we're in capture mode, remind them
1387 else {
1388 // Get the original instruction to remind them using current_options
1389 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1390 $this->current_action_instruction = $original_instruction;
1391 }
1392 }
1393
1394 $intent_info = '';
1395
1396 // Check chat mode
1397 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1398
1399 // Handle agent mode
1400 // Handle agent mode
1401 if ($chat_mode === 'agent') {
1402 // First, check for switch intent before doing anything else
1403 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1404
1405 // Capture action analysis for testing panel after intent check
1406 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1407 $testing_data['action_matches'] = $this->last_action_analysis;
1408 }
1409
1410 // Around line 506, in the agent mode handling section:
1411 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1412 // Update chat mode first
1413 update_option("mxchat_mode_{$session_id}", 'ai');
1414
1415 // Clear any existing PDF context to start fresh
1416 $this->clear_pdf_transients($session_id);
1417
1418 // Prepare clean switch response with explicit chat_mode
1419 $response_data = [
1420 'text' => $this->fallbackResponse['text'],
1421 'html' => $this->fallbackResponse['html'] ?? '',
1422 'session_id' => $session_id,
1423 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1424 ];
1425
1426 if ($testing_data !== null) {
1427 $response_data['testing_data'] = $testing_data;
1428 }
1429
1430 // Save the mode switch message
1431 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1432 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1433
1434 // Send response and exit
1435 wp_send_json($response_data);
1436 wp_die();
1437 } elseif (!$intent_matched) {
1438 // No intent matched, handle live agent message
1439 try {
1440 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1441
1442 $agent_response = [
1443 'status' => 'waiting_for_agent',
1444 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1445 ];
1446
1447 if ($testing_data !== null) {
1448 $agent_response['testing_data'] = $testing_data;
1449 }
1450
1451 wp_send_json_success($agent_response);
1452 } catch (\Exception $e) {
1453 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1454 }
1455 wp_die();
1456 }
1457 }
1458
1459 // Step 1: Check for new PDF URL in the message
1460 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1461 $new_pdf_url = $matches[0];
1462
1463 // Check if this is likely a PDF-related request
1464 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1465 $is_pdf_request = false;
1466
1467 foreach ($pdf_keywords as $keyword) {
1468 if (stripos($message, $keyword) !== false) {
1469 $is_pdf_request = true;
1470 break;
1471 }
1472 }
1473
1474 // If it looks like a PDF request or we're waiting for a PDF URL
1475 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1476 // Validate HTTPS
1477 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1478 // Extract filename from URL
1479 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1480
1481 // Clear previous PDF transients
1482 $this->clear_pdf_transients($session_id);
1483
1484 // Process new PDF using current_options
1485 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1486 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1487
1488 if ($embeddings === 'too_many_pages') {
1489 $error_text = sprintf(
1490 $current_options['pdf_intent_error_text'] ??
1491 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1492 $max_pages
1493 );
1494 $this->fallbackResponse['text'] = $error_text;
1495 } elseif ($embeddings) {
1496 // Store new PDF information
1497 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1498
1499 // If the filename is generic, create a more descriptive one
1500 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1501 strpos($pdf_filename, '.php') !== false) {
1502 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1503 }
1504
1505 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1506 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1507 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1508 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1509
1510 $success_text = $current_options['pdf_intent_success_text'] ??
1511 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1512
1513 $pdf_response = [
1514 'success' => true,
1515 'message' => $success_text,
1516 'data' => [
1517 'filename' => $pdf_filename
1518 ]
1519 ];
1520
1521 if ($testing_data !== null) {
1522 $pdf_response['testing_data'] = $testing_data;
1523 }
1524
1525 wp_send_json($pdf_response);
1526 wp_die();
1527 } else {
1528 $error_text = $current_options['pdf_intent_error_text'] ??
1529 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1530 $this->fallbackResponse['text'] = $error_text;
1531 }
1532
1533 $pdf_error_response = [
1534 'success' => false,
1535 'message' => $this->fallbackResponse['text']
1536 ];
1537
1538 if ($testing_data !== null) {
1539 $pdf_error_response['testing_data'] = $testing_data;
1540 }
1541
1542 wp_send_json($pdf_error_response);
1543 wp_die();
1544 }
1545 }
1546 }
1547
1548
1549 // Step 2: Detect intent and handle intent-based responses
1550 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1551
1552 // Capture action analysis for testing panel after intent check
1553 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1554 $testing_data['action_matches'] = $this->last_action_analysis;
1555 }
1556
1557 // Step 3: Handle the intent result appropriately
1558 if ($intent_result !== false) {
1559 // Intent was matched - ALWAYS send as JSON response, never streaming
1560
1561 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1562 // Intent returned a direct response array
1563 $response_data = [
1564 'text' => $intent_result['text'] ?? '',
1565 'html' => $intent_result['html'] ?? '',
1566 'session_id' => $session_id
1567 ];
1568
1569 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
1570 if (isset($intent_result['chat_mode'])) {
1571 $response_data['chat_mode'] = $intent_result['chat_mode'];
1572 }
1573
1574 if ($testing_data !== null) {
1575 $response_data['testing_data'] = $testing_data;
1576 }
1577
1578 wp_send_json($response_data);
1579 wp_die();
1580 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1581 // Intent returned true and set fallbackResponse
1582
1583 // SAVE TO TRANSCRIPT
1584 if (!empty($this->fallbackResponse['text'])) {
1585 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1586 }
1587 // Save product card HTML separately so it renders in transcripts
1588 if (!empty($this->fallbackResponse['html']) && strpos($this->fallbackResponse['html'], 'mxchat-product-card') !== false) {
1589 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1590 }
1591
1592 $response_data = [
1593 'text' => $this->fallbackResponse['text'] ?? '',
1594 'html' => $this->fallbackResponse['html'] ?? '',
1595 'session_id' => $session_id
1596 ];
1597
1598 if (isset($this->fallbackResponse['chat_mode'])) {
1599 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
1600 }
1601
1602 if ($testing_data !== null) {
1603 $response_data['testing_data'] = $testing_data;
1604 }
1605
1606 wp_send_json($response_data);
1607 wp_die();
1608 }
1609 }
1610
1611 // If we get here, no intent matched OR the intent didn't provide a usable response
1612
1613 // Step 4: Generate AI response
1614 // Get session start timestamp - when persistence is OFF, only include messages from this page load
1615 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
1616 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
1617 $this->mxchat_increment_chat_count();
1618
1619 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
1620 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
1621 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
1622
1623 // Check if the embedding generation returned an error
1624 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
1625 $error_message = $user_message_embedding['error'];
1626 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
1627
1628 // FIXED: Send error in appropriate format based on streaming mode
1629 if ($is_streaming) {
1630 echo "data: " . json_encode([
1631 'error' => true,
1632 'error_message' => $error_message,
1633 'error_code' => $error_code,
1634 'text' => $error_message,
1635 'message' => $error_message
1636 ]) . "\n\n";
1637 echo "data: [DONE]\n\n";
1638 flush();
1639 } else {
1640 wp_send_json_error([
1641 'error_message' => $error_message,
1642 'error_code' => $error_code
1643 ]);
1644 }
1645 wp_die();
1646 }
1647
1648 // Check if the embedding is valid
1649 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
1650 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1651
1652 // FIXED: Send error in appropriate format based on streaming mode
1653 if ($is_streaming) {
1654 echo "data: " . json_encode([
1655 'error' => true,
1656 'error_message' => $error_message,
1657 'error_code' => 'invalid_embedding',
1658 'text' => $error_message,
1659 'message' => $error_message
1660 ]) . "\n\n";
1661 echo "data: [DONE]\n\n";
1662 flush();
1663 } else {
1664 wp_send_json_error([
1665 'error_message' => $error_message,
1666 'error_code' => 'invalid_embedding'
1667 ]);
1668 }
1669 wp_die();
1670 }
1671
1672 // Build context with both knowledge base and PDF content if available
1673 $context_content = "User asked: '{$message}'\n\n";
1674
1675 // Add action instruction if present (add this right after the above line)
1676 if (!empty($this->current_action_instruction)) {
1677 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
1678 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
1679 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
1680 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1681
1682 // Clear the instruction after using it
1683 $this->current_action_instruction = null;
1684 }
1685
1686
1687 // Add page context if available and contextual awareness is enabled using current_options
1688 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
1689 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
1690 $context_content .= "Page URL: " . $page_context['url'] . "\n";
1691 $context_content .= "Page Title: " . $page_context['title'] . "\n";
1692 $context_content .= "Page Content: " . $page_context['content'] . "\n";
1693 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
1694 }
1695
1696 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
1697 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
1698
1699 // NEW: Also extract URLs from system instructions (only if citation links enabled)
1700 // Use fresh options to ensure we get the latest setting value
1701 $fresh_options = get_option('mxchat_options', []);
1702 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
1703
1704 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
1705 if ($citation_links_enabled && !empty($system_instructions)) {
1706 preg_match_all(
1707 '#\bhttps?://[^\s<>"\']+#i',
1708 $system_instructions,
1709 $system_instruction_urls
1710 );
1711
1712 if (!empty($system_instruction_urls[0])) {
1713 // Merge with existing valid URLs
1714 $this->current_valid_urls = array_merge(
1715 $this->current_valid_urls,
1716 $system_instruction_urls[0]
1717 );
1718 // Remove duplicates
1719 $this->current_valid_urls = array_unique($this->current_valid_urls);
1720
1721 error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
1722 }
1723 }
1724
1725 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
1726 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
1727 // Update testing data with the REAL similarity analysis
1728 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
1729 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1730 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
1731 }
1732 // ===== END SIMILARITY DATA CAPTURE =====
1733
1734 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
1735 if ($testing_data !== null && !empty($this->current_valid_urls)) {
1736 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
1737 error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
1738 }
1739
1740 if (!empty($relevant_content)) {
1741 $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
1742 } else {
1743 $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
1744 }
1745
1746 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
1747 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
1748 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
1749 $context_content .= "You may ONLY use these exact URLs in your response:\n";
1750 foreach ($this->current_valid_urls as $url) {
1751 $context_content .= "- " . $url . "\n";
1752 }
1753 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
1754 $context_content .= "===== END APPROVED URLS =====\n\n";
1755 }
1756
1757 // Check for and include PDF content
1758 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
1759 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
1760 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
1761 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
1762 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
1763 if (!empty($relevant_pdf_pages)) {
1764 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
1765 foreach ($relevant_pdf_pages as $page_data) {
1766 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
1767 }
1768 $context_content .= "\n";
1769 }
1770 }
1771
1772 // Check for and include Word content
1773 $word_url = get_transient('mxchat_word_url_' . $session_id);
1774 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
1775 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
1776 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
1777 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
1778 if (!empty($relevant_word_chunks)) {
1779 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
1780 foreach ($relevant_word_chunks as $chunk_data) {
1781 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
1782 }
1783 $context_content .= "\n";
1784 }
1785 }
1786
1787 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
1788
1789 // Extract model from current options for bot-specific model support
1790 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.1-chat-latest';
1791
1792 $response = $this->mxchat_generate_response(
1793 $context_content,
1794 $current_options['api_key'] ?? $this->options['api_key'],
1795 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
1796 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
1797 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
1798 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
1799 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
1800 $conversation_history,
1801 $is_streaming,
1802 $session_id,
1803 $testing_data,
1804 $selected_model
1805 );
1806
1807 // Handle streaming vs non-streaming responses
1808 if ($is_streaming) {
1809 // Check if streaming actually happened or if it fell back to regular response
1810 if ($response === true) {
1811 wp_die();
1812 }
1813 // If we get here, streaming fell back to regular response, continue
1814 // But if there's an error, we need to send it as SSE format since headers are already set
1815 if (is_array($response) && isset($response['error'])) {
1816 $error_message = $response['error'];
1817 $error_code = $response['error_code'] ?? 'api_error';
1818 // Send error in SSE format that the client JS can handle
1819 echo "data: " . json_encode([
1820 'error' => true,
1821 'error_message' => $error_message,
1822 'error_code' => $error_code,
1823 'text' => $error_message, // Also include as text for fallback handling
1824 'message' => $error_message
1825 ]) . "\n\n";
1826 echo "data: [DONE]\n\n";
1827 flush();
1828 wp_die();
1829 }
1830 }
1831
1832 // Check if the response is an error array (non-streaming mode)
1833 if (is_array($response) && isset($response['error'])) {
1834 wp_send_json_error([
1835 'error_message' => $response['error'],
1836 'error_code' => $response['error_code'] ?? 'api_error'
1837 ]);
1838 wp_die();
1839 }
1840
1841 // DEBUG: Check what we have
1842 error_log("=== BEFORE URL VALIDATION ===");
1843 error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
1844 error_log("current_valid_urls count: " . count($this->current_valid_urls));
1845 error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
1846
1847 // If we get here, the response is valid text - now validate URLs
1848 if (!empty($this->current_valid_urls)) {
1849 error_log("CALLING validate_and_clean_urls");
1850 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls);
1851 } else {
1852 error_log("SKIPPING validation - current_valid_urls is empty");
1853 }
1854 // ===== END URL VALIDATION =====
1855
1856 // Prepare RAG context data for storage (only include documents used for context)
1857 $rag_context_for_storage = null;
1858 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
1859 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
1860
1861 if ($has_rag_data || $has_action_data) {
1862 $rag_context_for_storage = [];
1863
1864 // Add RAG/source data if available
1865 if ($has_rag_data) {
1866 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
1867 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
1868 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
1869 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
1870 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1871 }
1872
1873 // Add action analysis data if available
1874 if ($has_action_data) {
1875 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
1876 }
1877 }
1878
1879 // Save the cleaned response with RAG context
1880 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
1881
1882 // Step 5: Save additional content if available
1883 if (!empty($this->productCardHtml)) {
1884 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
1885 }
1886
1887 if (!empty($this->fallbackResponse['html'])) {
1888 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1889 }
1890
1891 // Step 6: Return the response
1892 // DEBUG: Check if newlines exist in the response
1893 error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
1894 error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
1895 error_log("Response first 500 chars: " . substr($response, 0, 500));
1896
1897 $response_data = [
1898 'text' => $response,
1899 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
1900 'session_id' => $session_id
1901 ];
1902
1903 // Always add testing data for admins (no toggle needed)
1904 if ($testing_data !== null) {
1905 $response_data['testing_data'] = $testing_data;
1906 }
1907
1908 wp_send_json($response_data);
1909 wp_die();
1910 }
1911
1912 /**
1913 * Get bot-specific options for multi-bot functionality
1914 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
1915 */
1916 // Also debug the bot options retrieval
1917 private function get_bot_options($bot_id = 'default') {
1918 error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
1919
1920 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1921 error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
1922 return array();
1923 }
1924
1925 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1926
1927 if (!empty($bot_options)) {
1928 error_log("MXCHAT DEBUG: Got bot-specific options from filter");
1929 if (isset($bot_options['similarity_threshold'])) {
1930 error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
1931 }
1932 }
1933
1934 return is_array($bot_options) ? $bot_options : array();
1935 }
1936
1937 /**
1938 * Get bot-specific Pinecone configuration
1939 * Used in the knowledge retrieval functions
1940 */
1941 // Also add debugging to your get_bot_pinecone_config function
1942 private function get_bot_pinecone_config($bot_id = 'default') {
1943 error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
1944
1945 // If default bot or multi-bot add-on not active, use default Pinecone config
1946 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1947 error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
1948 $addon_options = get_option('mxchat_pinecone_addon_options', array());
1949 $config = array(
1950 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
1951 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
1952 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
1953 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
1954 );
1955 error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
1956 return $config;
1957 }
1958
1959 error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
1960
1961 // Hook for multi-bot add-on to provide bot-specific Pinecone config
1962 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1963
1964 if (!empty($bot_pinecone_config)) {
1965 error_log("MXCHAT DEBUG: Got bot-specific config from filter");
1966 error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
1967 error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
1968 error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
1969 } else {
1970 error_log("MXCHAT DEBUG: Filter returned empty config!");
1971 }
1972
1973 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
1974 }
1975
1976
1977 // Updated function to check intents and invoke the callback function
1978 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
1979 global $wpdb;
1980 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1981
1982 // Get the current bot_id
1983 $current_bot_id = $this->get_current_bot_id($session_id);
1984
1985 // Generate the user embedding
1986 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
1987
1988 // Check if embedding generation returned an error
1989 if (is_array($user_embedding) && isset($user_embedding['error'])) {
1990 $error_message = $user_embedding['error'];
1991 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
1992
1993 // FIXED: Send error in appropriate format based on streaming mode
1994 if ($this->is_streaming) {
1995 echo "data: " . json_encode([
1996 'error' => true,
1997 'error_message' => $error_message,
1998 'error_code' => $error_code,
1999 'text' => $error_message,
2000 'message' => $error_message
2001 ]) . "\n\n";
2002 echo "data: [DONE]\n\n";
2003 flush();
2004 } else {
2005 wp_send_json_error([
2006 'error_message' => $error_message,
2007 'error_code' => $error_code
2008 ]);
2009 }
2010 wp_die();
2011 }
2012
2013 // Check if embedding is valid
2014 if (!is_array($user_embedding) || empty($user_embedding)) {
2015 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2016
2017 // FIXED: Send error in appropriate format based on streaming mode
2018 if ($this->is_streaming) {
2019 echo "data: " . json_encode([
2020 'error' => true,
2021 'error_message' => $error_message,
2022 'error_code' => 'invalid_embedding',
2023 'text' => $error_message,
2024 'message' => $error_message
2025 ]) . "\n\n";
2026 echo "data: [DONE]\n\n";
2027 flush();
2028 } else {
2029 wp_send_json_error([
2030 'error_message' => $error_message,
2031 'error_code' => 'invalid_embedding'
2032 ]);
2033 }
2034 wp_die();
2035 }
2036
2037 // Fetch intents from the database
2038 $table_name = $wpdb->prefix . 'mxchat_intents';
2039 if ($chat_mode === 'agent') {
2040 $query = $wpdb->prepare(
2041 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2042 'mxchat_handle_switch_to_chatbot_intent'
2043 );
2044 $intents = $wpdb->get_results($query);
2045 } else {
2046 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2047 }
2048
2049 if (empty($intents)) {
2050 return false;
2051 }
2052
2053 $highest_similarity = -INF;
2054 $matched_intent = null;
2055
2056 // Array to store action analysis for testing panel
2057 $action_analysis = [];
2058
2059 foreach ($intents as $intent) {
2060 // Additional check for enabled state
2061 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2062 if (!$is_enabled) {
2063 continue;
2064 }
2065
2066 // Check if this action is enabled for the current bot
2067 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2068 continue;
2069 }
2070
2071 $intent_embedding_serialized = $intent->embedding_vector;
2072 $intent_embedding = $intent_embedding_serialized
2073 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2074 : null;
2075
2076 if (!is_array($intent_embedding)) {
2077 continue;
2078 }
2079
2080 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2081 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2082
2083 // Store action analysis data for testing panel
2084 $action_analysis[] = [
2085 'intent_label' => $intent->intent_label,
2086 'callback_function' => $intent->callback_function,
2087 'similarity' => round($similarity, 4),
2088 'similarity_percentage' => round($similarity * 100, 2),
2089 'threshold' => $intent_threshold,
2090 'threshold_percentage' => round($intent_threshold * 100, 2),
2091 'above_threshold' => $similarity >= $intent_threshold,
2092 'triggered' => false // Will be updated below if this intent is triggered
2093 ];
2094
2095 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2096 $highest_similarity = $similarity;
2097 $matched_intent = $intent;
2098 }
2099 }
2100
2101 // Mark the triggered action if any
2102 if ($matched_intent) {
2103 foreach ($action_analysis as &$action) {
2104 if ($action['intent_label'] === $matched_intent->intent_label) {
2105 $action['triggered'] = true;
2106 break;
2107 }
2108 }
2109 }
2110
2111 // Sort actions by similarity (highest first) and store for testing panel
2112 usort($action_analysis, function($a, $b) {
2113 return $b['similarity'] <=> $a['similarity'];
2114 });
2115
2116 // Store action analysis for testing panel capture
2117 $this->last_action_analysis = $action_analysis;
2118
2119 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2120 if ($matched_intent) {
2121 // If the callback is a method on this instance (core callback), call it directly
2122 if (method_exists($this, $matched_intent->callback_function)) {
2123 $callback_result = call_user_func(
2124 [$this, $matched_intent->callback_function],
2125 $message,
2126 $user_id,
2127 $session_id,
2128 $matched_intent,
2129 $user_context ?? null
2130 );
2131 } else {
2132 // Otherwise, use apply_filters for add-on callbacks
2133 $callback_result = apply_filters(
2134 $matched_intent->callback_function,
2135 false,
2136 $message,
2137 $user_id,
2138 $session_id,
2139 $matched_intent
2140 );
2141 }
2142
2143 // Handle the callback result properly
2144 if ($callback_result !== false) {
2145 // If callback returned an array with chat_mode, use it directly
2146 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2147 $this->fallbackResponse = $callback_result;
2148 return $callback_result; // Return the full array
2149 } else {
2150 $this->fallbackResponse = $callback_result;
2151 return true;
2152 }
2153 }
2154 }
2155
2156 return false;
2157 }
2158
2159 /**
2160 * Check if an action is enabled for a specific bot
2161 */
2162 private function is_action_enabled_for_bot($intent, $bot_id) {
2163 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2164 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2165 return true;
2166 }
2167
2168 $enabled_bots = json_decode($intent->enabled_bots, true);
2169
2170 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2171 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2172 return true;
2173 }
2174
2175 // Check if the current bot is in the enabled bots list
2176 return in_array($bot_id, $enabled_bots);
2177 }
2178
2179 // Helper function to clear PDF and Word document related transients
2180 private function clear_pdf_transients($session_id) {
2181 // PDF transients
2182 delete_transient('mxchat_pdf_url_' . $session_id);
2183 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2184 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2185 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2186
2187 // Word document transients
2188 delete_transient('mxchat_word_url_' . $session_id);
2189 delete_transient('mxchat_word_filename_' . $session_id);
2190 delete_transient('mxchat_word_embeddings_' . $session_id);
2191 delete_transient('mxchat_include_word_in_context_' . $session_id);
2192 delete_transient('mxchat_waiting_for_word_' . $session_id);
2193 }
2194
2195
2196
2197 //verified good
2198 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2199 // Get the user's original instruction/message
2200 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2201
2202 // Set instruction for AI - just pass along what the user wanted to say
2203 $this->current_action_instruction = $user_instruction;
2204
2205 // Set the transient to track email capture flow
2206 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2207
2208 // Return false to let the AI generate the response
2209 return false;
2210 }
2211
2212 public function mxchat_generate_image($message, $user_id, $session_id) {
2213 //error_log("Starting image generation for message: " . $message);
2214
2215 // Prepare a prompt for OpenAI image generation
2216 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2217
2218 // Use the existing OpenAI API key
2219 $openai_api_key = sanitize_text_field($this->options['api_key']);
2220
2221 // Call OpenAI GPT Image to generate an image
2222 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2223
2224 // Check if the response contains an image URL
2225 if (isset($image_response['imageUrl'])) {
2226 $image_url = esc_url_raw($image_response['imageUrl']);
2227
2228 // Construct the HTML with a CSS class instead of inline styles
2229 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2230 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2231
2232 // Save the bot message with both text and HTML
2233 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2234 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2235
2236 // Set the fallback response for the chat handler
2237 $this->fallbackResponse = [
2238 'text' => $response_text,
2239 'html' => $response_html,
2240 'images' => [$image_url]
2241 ];
2242
2243 // For debugging/verification - Use json_encode to verify what's being set
2244 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2245
2246 // Return the response directly instead of relying on the property
2247 return $this->fallbackResponse;
2248 } else {
2249 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2250
2251 // Save the error message
2252 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2253
2254 // Set the fallback response for the chat handler
2255 $this->fallbackResponse = [
2256 'text' => $response_text,
2257 'html' => '',
2258 'images' => []
2259 ];
2260
2261 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2262 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2263
2264 // Return the response directly instead of relying on the property
2265 return $this->fallbackResponse;
2266 }
2267 }
2268
2269 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2270 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2271
2272 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2273 if (empty($gemini_api_key)) {
2274 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2275 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2276 return ['text' => $response_text, 'html' => '', 'images' => []];
2277 }
2278
2279 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2280
2281 if (isset($image_response['imageUrl'])) {
2282 $image_url = esc_url_raw($image_response['imageUrl']);
2283
2284 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2285 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2286
2287 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2288 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2289
2290 $this->fallbackResponse = [
2291 'text' => $response_text,
2292 'html' => $response_html,
2293 'images' => [$image_url]
2294 ];
2295
2296 return $this->fallbackResponse;
2297 } else {
2298 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2299
2300 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2301
2302 $this->fallbackResponse = [
2303 'text' => $response_text,
2304 'html' => '',
2305 'images' => []
2306 ];
2307
2308 return $this->fallbackResponse;
2309 }
2310 }
2311
2312 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
2313 $extension = ($mime_type === 'image/jpeg') ? 'jpg' : 'png';
2314 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
2315 $decoded = base64_decode($base64_data);
2316
2317 if ($decoded === false) {
2318 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
2319 }
2320
2321 $upload = wp_upload_bits($filename, null, $decoded);
2322
2323 if (!empty($upload['error'])) {
2324 return new \WP_Error('upload_failed', $upload['error']);
2325 }
2326
2327 $attach_id = wp_insert_attachment([
2328 'post_mime_type' => $mime_type,
2329 'post_title' => $prefix,
2330 'post_content' => '',
2331 'post_status' => 'inherit',
2332 ], $upload['file']);
2333
2334 if (is_wp_error($attach_id)) {
2335 return $attach_id;
2336 }
2337
2338 require_once ABSPATH . 'wp-admin/includes/image.php';
2339 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
2340 wp_update_attachment_metadata($attach_id, $metadata);
2341
2342 return esc_url_raw(wp_get_attachment_url($attach_id));
2343 }
2344
2345 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
2346 $api_url = 'https://api.openai.com/v1/images/generations';
2347 $body = json_encode([
2348 'prompt' => sanitize_text_field($prompt),
2349 'n' => 1,
2350 'size' => '1024x1024',
2351 'quality' => 'medium',
2352 'output_format' => 'png',
2353 'model' => sanitize_text_field($model),
2354 ]);
2355
2356 $args = [
2357 'body' => $body,
2358 'headers' => [
2359 'Content-Type' => 'application/json',
2360 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
2361 ],
2362 'method' => 'POST',
2363 'timeout' => absint($timeout),
2364 ];
2365
2366 $response = wp_remote_post($api_url, $args);
2367
2368 if (is_wp_error($response)) {
2369 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2370 }
2371
2372 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2373
2374 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
2375 if ($b64) {
2376 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
2377 if (is_wp_error($saved_url)) {
2378 return ['error' => $saved_url->get_error_message()];
2379 }
2380 return ['imageUrl' => $saved_url];
2381 } else {
2382 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2383 }
2384 }
2385
2386 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
2387 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
2388
2389 $body = json_encode([
2390 'instances' => [['prompt' => sanitize_text_field($prompt)]],
2391 'parameters' => [
2392 'sampleCount' => 1,
2393 'aspectRatio' => '1:1',
2394 ],
2395 ]);
2396
2397 $args = [
2398 'body' => $body,
2399 'headers' => [
2400 'Content-Type' => 'application/json',
2401 'x-goog-api-key' => sanitize_text_field($api_key),
2402 ],
2403 'method' => 'POST',
2404 'timeout' => absint($timeout),
2405 ];
2406
2407 $response = wp_remote_post($api_url, $args);
2408
2409 if (is_wp_error($response)) {
2410 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2411 }
2412
2413 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2414
2415 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
2416 if ($b64) {
2417 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
2418 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
2419 if (is_wp_error($saved_url)) {
2420 return ['error' => $saved_url->get_error_message()];
2421 }
2422 return ['imageUrl' => $saved_url];
2423 } else {
2424 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2425 }
2426 }
2427
2428 /**
2429 * Handle web search requests.
2430 *
2431 * Sends the refined search query to the Brave Search API and uses the
2432 * results to generate a conversational response with the AI model.
2433 *
2434 * @since 1.0.0
2435 * @param string $message The user's search query.
2436 * @param string $user_id The user identifier.
2437 * @param string $session_id The current session ID.
2438 * @return array Response array containing text with embedded HTML links
2439 */
2440 public function mxchat_handle_search_request($message, $user_id, $session_id) {
2441 // Step 1: Interpret and refine the search query
2442 $refined_search_query = $this->mxchat_interpret_search_query($message);
2443 if (empty($refined_search_query)) {
2444 return array(
2445 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
2446 'html' => ''
2447 );
2448 }
2449
2450 // Retrieve and validate API settings
2451 $options = get_option('mxchat_options');
2452 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2453 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
2454
2455 if (empty($api_key)) {
2456 return array(
2457 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
2458 'html' => ''
2459 );
2460 }
2461
2462 // Build the API request URL
2463 $api_url = add_query_arg(
2464 array(
2465 'q' => rawurlencode($refined_search_query),
2466 'count' => $results_count,
2467 'text_decorations' => 'true',
2468 'rich_data' => 'true',
2469 ),
2470 'https://api.search.brave.com/res/v1/web/search'
2471 );
2472
2473 // Attempt to retrieve cached results first
2474 $transient_key = 'mxchat_search_' . md5($refined_search_query);
2475 $results = get_transient($transient_key);
2476
2477 if (false === $results) {
2478 // SECURITY FIX: Changed to wp_safe_remote_get
2479 $response = wp_safe_remote_get(
2480 $api_url,
2481 array(
2482 'headers' => array(
2483 'Accept' => 'application/json',
2484 'Accept-Encoding' => 'gzip',
2485 'X-Subscription-Token'=> $api_key,
2486 ),
2487 'timeout' => 10,
2488 )
2489 );
2490
2491 if (is_wp_error($response)) {
2492 return array(
2493 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
2494 'html' => ''
2495 );
2496 }
2497
2498 $results = json_decode(wp_remote_retrieve_body($response), true);
2499
2500 if (json_last_error() !== JSON_ERROR_NONE) {
2501 return array(
2502 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
2503 'html' => ''
2504 );
2505 }
2506
2507 // Cache results for one hour
2508 set_transient($transient_key, $results, HOUR_IN_SECONDS);
2509 }
2510
2511 // Process results
2512 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
2513 // Create a more straightforward summary with HTML links
2514 $search_results_text = '';
2515
2516 // Add a simple intro
2517 $search_results_text .= sprintf(
2518 esc_html__("Here's what I found about '%s':", 'mxchat'),
2519 esc_html($refined_search_query)
2520 );
2521
2522 // Add the top results with HTML links
2523 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
2524 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
2525 $url = isset($result['url']) ? esc_url($result['url']) : '';
2526 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
2527
2528 // Add a line break after the intro
2529 $search_results_text .= '<br><br>';
2530
2531 // Add title as a link
2532 $search_results_text .= sprintf(
2533 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
2534 $url,
2535 $title
2536 );
2537
2538 // Add a condensed description
2539 $search_results_text .= sprintf("%s", $description);
2540 }
2541
2542 // Save to chat history
2543 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
2544
2545 // Return the formatted text with embedded HTML links
2546 return array(
2547 'text' => $search_results_text,
2548 'html' => ''
2549 );
2550 } else {
2551 return array(
2552 'text' => sprintf(
2553 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
2554 esc_html($refined_search_query)
2555 ),
2556 'html' => ''
2557 );
2558 }
2559 }
2560
2561 //very good
2562 /**
2563 * Handle image search requests from the chatbot
2564 *
2565 * @param string $message The user's search query
2566 * @param int $user_id The user's ID
2567 * @param string $session_id The chat session ID
2568 * @return array Response array with text and HTML content
2569 */
2570 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
2571 // Step 1: Interpret the search query using the user's selected AI model
2572 $refined_search_query = $this->mxchat_interpret_search_query($message);
2573
2574 // If no query was interpreted, return a fallback message
2575 if (empty($refined_search_query)) {
2576 return array(
2577 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
2578 'html' => "",
2579 );
2580 }
2581
2582 // Brave API URL
2583 $api_url = 'https://api.search.brave.com/res/v1/images/search';
2584
2585 // Retrieve Brave API settings
2586 $options = get_option('mxchat_options');
2587 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2588
2589 if (empty($api_key)) {
2590 return array(
2591 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
2592 'html' => "",
2593 );
2594 }
2595
2596 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2597 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
2598
2599 // Append query parameters based on settings
2600 $api_url = add_query_arg([
2601 'q' => rawurlencode($refined_search_query),
2602 'count' => $image_count,
2603 'safesearch' => $safe_search,
2604 ], $api_url);
2605
2606 // Implement caching
2607 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
2608 $body = get_transient($transient_key);
2609
2610 if (false === $body) {
2611 $args = [
2612 'headers' => [
2613 'Accept' => 'application/json',
2614 'Accept-Encoding' => 'gzip',
2615 'X-Subscription-Token' => $api_key,
2616 ],
2617 'timeout' => 10,
2618 ];
2619
2620 // SECURITY FIX: Changed to wp_safe_remote_get
2621 $response = wp_safe_remote_get($api_url, $args);
2622
2623 if (is_wp_error($response)) {
2624 return array(
2625 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
2626 'html' => "",
2627 );
2628 }
2629
2630 $body = json_decode(wp_remote_retrieve_body($response), true);
2631 set_transient($transient_key, $body, HOUR_IN_SECONDS);
2632 }
2633
2634 // Process the API response
2635 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
2636 $html_output = '<div class="mxchat-image-gallery">';
2637
2638 // Get the configured image count (1-6)
2639 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2640 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
2641
2642 // Use only the requested number of images
2643 for ($i = 0; $i < $display_count; $i++) {
2644 $image = $body['results'][$i];
2645 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
2646 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
2647 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
2648
2649 if ($image_url && $thumbnail_url) {
2650 $html_output .= '<div class="mxchat-image-item">';
2651 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
2652 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
2653 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
2654 $html_output .= '</a></div>';
2655 }
2656 }
2657
2658 $html_output .= '</div>';
2659
2660 // Create response text
2661 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
2662
2663 // Save both response text and HTML to chat history
2664 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2665 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
2666
2667 // Return the combined response
2668 return array(
2669 'text' => $response_text,
2670 'html' => $html_output,
2671 );
2672 } else {
2673 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
2674
2675 // Save the error message to chat history
2676 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2677
2678 return array(
2679 'text' => $response_text,
2680 'html' => "",
2681 );
2682 }
2683 }
2684
2685 /**
2686 * Interpret the search query using the user's selected AI model
2687 *
2688 * @param string $user_query The original query from the user
2689 * @return string The refined search query
2690 */
2691 public function mxchat_interpret_search_query($user_query) {
2692 $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');
2693
2694 // Get options and determine the selected model
2695 $options = $this->options ?? get_option('mxchat_options');
2696 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest';
2697
2698 // Extract model prefix to determine the provider
2699 $model_parts = explode('-', $selected_model);
2700 $provider = strtolower($model_parts[0]);
2701
2702 // Determine which API key to use based on the provider
2703 switch ($provider) {
2704 case 'gemini':
2705 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
2706 if (empty($api_key)) {
2707 return sanitize_text_field($user_query); // Default to original query if API key missing
2708 }
2709 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
2710
2711 case 'claude':
2712 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
2713 if (empty($api_key)) {
2714 return sanitize_text_field($user_query);
2715 }
2716 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
2717
2718 case 'grok':
2719 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
2720 if (empty($api_key)) {
2721 return sanitize_text_field($user_query);
2722 }
2723 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
2724
2725 case 'deepseek':
2726 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
2727 if (empty($api_key)) {
2728 return sanitize_text_field($user_query);
2729 }
2730 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
2731
2732 case 'gpt':
2733 default:
2734 // Default to OpenAI for custom models or unrecognized prefixes
2735 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
2736 if (empty($api_key)) {
2737 return sanitize_text_field($user_query);
2738 }
2739 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
2740 }
2741 }
2742
2743 /**
2744 * Interpret query using OpenAI models
2745 */
2746 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.1-chat-latest') {
2747 $url = 'https://api.openai.com/v1/chat/completions';
2748 $args = [
2749 'headers' => [
2750 'Authorization' => 'Bearer ' . $api_key,
2751 'Content-Type' => 'application/json',
2752 ],
2753 'body' => wp_json_encode([
2754 'model' => $model,
2755 'messages' => [
2756 ['role' => 'system', 'content' => $system_prompt],
2757 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2758 ],
2759 'temperature' => 0.2,
2760 'max_tokens' => 20,
2761 ]),
2762 'method' => 'POST',
2763 'timeout' => 15,
2764 ];
2765
2766 $response = wp_remote_post($url, $args);
2767 if (is_wp_error($response)) {
2768 return sanitize_text_field($user_query);
2769 }
2770
2771 $body = json_decode(wp_remote_retrieve_body($response), true);
2772 return isset($body['choices'][0]['message']['content'])
2773 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
2774 : sanitize_text_field($user_query);
2775 }
2776
2777 /**
2778 * Interpret query using Claude models
2779 */
2780 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
2781 $url = 'https://api.anthropic.com/v1/messages';
2782
2783 $args = [
2784 'headers' => [
2785 'Content-Type' => 'application/json',
2786 'x-api-key' => $api_key,
2787 'anthropic-version' => '2023-06-01',
2788 ],
2789 'body' => wp_json_encode([
2790 'model' => $model,
2791 'system' => $system_prompt,
2792 'messages' => [
2793 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
2794 ],
2795 'max_tokens' => 20,
2796 'temperature' => 0.2,
2797 ]),
2798 'method' => 'POST',
2799 'timeout' => 15,
2800 ];
2801
2802 $response = wp_remote_post($url, $args);
2803 if (is_wp_error($response)) {
2804 return sanitize_text_field($user_query);
2805 }
2806
2807 $body = json_decode(wp_remote_retrieve_body($response), true);
2808 if (!empty($body['content'][0]['text'])) {
2809 return sanitize_text_field(trim($body['content'][0]['text']));
2810 }
2811
2812 return sanitize_text_field($user_query);
2813 }
2814
2815 /**
2816 * Interpret query using Gemini models
2817 */
2818 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
2819 // Use v1beta for preview models, v1 for stable models
2820 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
2821
2822 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
2823
2824 $args = [
2825 'headers' => [
2826 'Content-Type' => 'application/json',
2827 ],
2828 'body' => wp_json_encode([
2829 'contents' => [
2830 [
2831 'role' => 'user',
2832 'parts' => [
2833 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
2834 ]
2835 ]
2836 ],
2837 'generationConfig' => [
2838 'temperature' => 0.2,
2839 'maxOutputTokens' => 20,
2840 ],
2841 ]),
2842 'method' => 'POST',
2843 'timeout' => 15,
2844 ];
2845
2846 $response = wp_remote_post($url, $args);
2847 if (is_wp_error($response)) {
2848 return sanitize_text_field($user_query);
2849 }
2850
2851 $body = json_decode(wp_remote_retrieve_body($response), true);
2852 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
2853 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
2854 }
2855
2856 return sanitize_text_field($user_query);
2857 }
2858
2859 /**
2860 * Interpret query using X.AI (Grok) models
2861 */
2862 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
2863 $url = 'https://api.xai.com/v1/chat/completions';
2864
2865 $args = [
2866 'headers' => [
2867 'Content-Type' => 'application/json',
2868 'Authorization' => 'Bearer ' . $api_key,
2869 ],
2870 'body' => wp_json_encode([
2871 'model' => $model,
2872 'messages' => [
2873 ['role' => 'system', 'content' => $system_prompt],
2874 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2875 ],
2876 'temperature' => 0.2,
2877 'max_tokens' => 20,
2878 ]),
2879 'method' => 'POST',
2880 'timeout' => 15,
2881 ];
2882
2883 $response = wp_remote_post($url, $args);
2884 if (is_wp_error($response)) {
2885 return sanitize_text_field($user_query);
2886 }
2887
2888 $body = json_decode(wp_remote_retrieve_body($response), true);
2889 if (isset($body['choices'][0]['message']['content'])) {
2890 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2891 }
2892
2893 return sanitize_text_field($user_query);
2894 }
2895
2896 /**
2897 * Interpret query using DeepSeek models
2898 */
2899 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
2900 $url = 'https://api.deepseek.com/v1/chat/completions';
2901
2902 $args = [
2903 'headers' => [
2904 'Content-Type' => 'application/json',
2905 'Authorization' => 'Bearer ' . $api_key,
2906 ],
2907 'body' => wp_json_encode([
2908 'model' => $model,
2909 'messages' => [
2910 ['role' => 'system', 'content' => $system_prompt],
2911 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2912 ],
2913 'temperature' => 0.2,
2914 'max_tokens' => 20,
2915 ]),
2916 'method' => 'POST',
2917 'timeout' => 15,
2918 ];
2919
2920 $response = wp_remote_post($url, $args);
2921 if (is_wp_error($response)) {
2922 return sanitize_text_field($user_query);
2923 }
2924
2925 $body = json_decode(wp_remote_retrieve_body($response), true);
2926 if (isset($body['choices'][0]['message']['content'])) {
2927 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2928 }
2929
2930 return sanitize_text_field($user_query);
2931 }
2932
2933 //very good
2934 private function add_email_to_loops($email) {
2935 // Sanitize the email
2936 $email = sanitize_email($email);
2937
2938 // Retrieve and sanitize options
2939 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
2940 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
2941
2942 // Check for missing API key or mailing list ID
2943 if (empty($api_key) || empty($mailing_list_id)) {
2944 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
2945 return;
2946 }
2947
2948 $data = array(
2949 'email' => $email,
2950 'subscribed' => true,
2951 'source' => __('MxChat AI Chatbot', 'mxchat'),
2952 'mailingLists' => array($mailing_list_id => true),
2953 );
2954
2955 $url = 'https://app.loops.so/api/v1/contacts/create';
2956 $args = array(
2957 'body' => wp_json_encode($data),
2958 'headers' => array(
2959 'Authorization' => 'Bearer ' . $api_key,
2960 'Content-Type' => 'application/json',
2961 ),
2962 'method' => 'POST',
2963 'timeout' => 45,
2964 );
2965
2966 $response = wp_remote_post($url, $args);
2967
2968 // Handle errors in the API request
2969 if (is_wp_error($response)) {
2970 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
2971 return;
2972 }
2973
2974 // Check for non-200 HTTP responses
2975 $response_code = wp_remote_retrieve_response_code($response);
2976 if ($response_code != 200) {
2977 $response_body = wp_remote_retrieve_body($response);
2978 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
2979 }
2980 }
2981
2982 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
2983 // Get the maximum number of pages allowed from admin settings
2984 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
2985
2986 // Retrieve options for dynamic texts
2987 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
2988 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
2989 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
2990
2991 // Check for explicit request for new PDF
2992 $new_pdf_requested = stripos($message, 'new') !== false ||
2993 stripos($message, 'another') !== false ||
2994 stripos($message, 'different') !== false;
2995
2996 // If user mentions adding/reading a PDF, set waiting flag
2997 if (stripos($message, 'pdf') !== false ||
2998 stripos($message, 'document') !== false ||
2999 stripos($message, 'read') !== false) {
3000 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3001 $this->fallbackResponse['text'] = $trigger_text;
3002 return;
3003 }
3004
3005 // If we're waiting for a URL or user requested new PDF
3006 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3007 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3008 // Process URL... (rest of your existing URL processing code)
3009 } else {
3010 $this->fallbackResponse['text'] = $trigger_text;
3011 }
3012 return;
3013 }
3014
3015 // Default to proceeding with conversation if no specific PDF action is needed
3016 $this->fallbackResponse['text'] = '';
3017 }
3018
3019
3020 /**
3021 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3022 */
3023 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3024 // CLEAR DEBUG LOGGING
3025 //error_log("=== MXCHAT PDF PROCESSING START ===");
3026 //error_log("PDF Source: " . $pdf_source);
3027 //error_log("Max Pages: " . $max_pages);
3028 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3029
3030 // Check if Advanced Claude Toolbar is available and enabled
3031 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3032 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3033
3034 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3035 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3036
3037 if ($claude_available && $claude_enabled) {
3038 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3039
3040 // Attempt Claude processing first
3041 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3042
3043 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
3044 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
3045 //error_log("Claude returned " . count($claude_result) . " processed pages");
3046
3047 // Log first page details for verification
3048 if (isset($claude_result[0])) {
3049 $first_page = $claude_result[0];
3050 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
3051 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
3052 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
3053 }
3054
3055 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
3056 return $claude_result;
3057 } else {
3058 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
3059 //error_log("Claude result type: " . gettype($claude_result));
3060 if (is_array($claude_result)) {
3061 //error_log("Claude result count: " . count($claude_result));
3062 }
3063 }
3064 }
3065
3066 // Fallback to basic processing
3067 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
3068
3069 $upload_dir = wp_upload_dir();
3070 $temp_file = null;
3071
3072 try {
3073 // Your existing basic processing code here...
3074 // (I'll include the key parts with debug logging)
3075
3076 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
3077 //error_log("Downloading PDF from URL...");
3078
3079 // SECURITY FIX: Validate URL before processing
3080 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
3081 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
3082 return false;
3083 }
3084
3085 $temp_file = wp_tempnam($pdf_source);
3086
3087 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
3088 $response = wp_safe_remote_get($pdf_source, [
3089 'timeout' => 60,
3090 'headers' => ['User-Agent' => 'MxChat PDF Processor']
3091 ]);
3092
3093 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
3094 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
3095 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
3096 return false;
3097 }
3098
3099 global $wp_filesystem;
3100 if (empty($wp_filesystem)) {
3101 require_once ABSPATH . 'wp-admin/includes/file.php';
3102 WP_Filesystem();
3103 }
3104 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
3105 //error_log("✅ PDF downloaded successfully");
3106 } else {
3107 $temp_file = $pdf_source;
3108 //error_log("Using local PDF file: " . $temp_file);
3109 }
3110
3111 // Parse PDF
3112 //error_log("Parsing PDF with basic parser...");
3113 mxchat_load_pdf_parser();
3114 $parser = new \Smalot\PdfParser\Parser();
3115 $pdf = $parser->parseFile($temp_file);
3116 $pages = $pdf->getPages();
3117
3118 //error_log("PDF contains " . count($pages) . " pages");
3119
3120 if (count($pages) > $max_pages) {
3121 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
3122 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
3123 unlink($temp_file);
3124 }
3125 return 'too_many_pages';
3126 }
3127
3128 $embeddings = [];
3129 $processed_pages = 0;
3130
3131 foreach ($pages as $page_number => $page) {
3132 $text = $page->getText();
3133
3134 if (empty(trim($text))) {
3135 //error_log("Skipping empty page: " . ($page_number + 1));
3136 continue;
3137 }
3138
3139 $text = $this->mxchat_clean_text($text);
3140
3141 $embedding = $this->mxchat_generate_embedding(
3142 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
3143 $this->options['api_key']
3144 );
3145
3146 if ($embedding) {
3147 $embeddings[] = [
3148 'page_number' => $page_number + 1,
3149 'embedding' => $embedding,
3150 'text' => $text,
3151 'enhanced' => false, // CLEARLY MARK AS BASIC
3152 'processing_method' => 'basic_pdf_parser'
3153 ];
3154 $processed_pages++;
3155 }
3156 }
3157
3158 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
3159
3160 // Cleanup
3161 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3162 unlink($temp_file);
3163 }
3164
3165 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
3166 return $embeddings;
3167
3168 } catch (\Exception $e) {
3169 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
3170 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3171 unlink($temp_file);
3172 }
3173 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
3174 return false;
3175 }
3176 }
3177
3178
3179 /**
3180 * Validate PDF URL for security
3181 * Prevents SSRF attacks by blocking dangerous URLs
3182 */
3183
3184 private function mxchat_is_safe_pdf_url($url) {
3185 // Use WordPress core function for comprehensive validation
3186 // This blocks localhost, private IPs, and reserved IP ranges
3187 $validated_url = wp_http_validate_url($url);
3188
3189 if ($validated_url === false) {
3190 return false;
3191 }
3192
3193 // Additional check: only allow HTTP/HTTPS schemes
3194 $parsed = parse_url($url);
3195 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
3196 return false;
3197 }
3198
3199 return true;
3200 }
3201
3202
3203 private function mxchat_clean_text($text) {
3204 // Remove excessive whitespace
3205 $text = preg_replace('/\s+/', ' ', $text);
3206
3207 // Remove control characters except newlines and tabs
3208 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
3209
3210 // Normalize line endings
3211 $text = str_replace(["\r\n", "\r"], "\n", $text);
3212
3213 // Trim whitespace
3214 $text = trim($text);
3215
3216 return $text;
3217 }
3218
3219 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
3220 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
3221
3222 $most_relevant = null;
3223 $highest_similarity = -INF;
3224
3225 foreach ($embeddings as $page_data) {
3226 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
3227
3228 if ($similarity > $highest_similarity) {
3229 $highest_similarity = $similarity;
3230 $most_relevant = $page_data['page_number'];
3231 }
3232 }
3233
3234 if (!is_null($most_relevant)) {
3235 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
3236 return array_filter($embeddings, function ($page) use ($page_numbers) {
3237 return in_array($page['page_number'], $page_numbers);
3238 });
3239 }
3240
3241 return [];
3242 }
3243
3244
3245 public function handle_pdf_upload() {
3246 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3247
3248 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
3249 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
3250 return;
3251 }
3252
3253 // SECURITY FIX: Check if PDF uploads are enabled in settings
3254 $options = get_option('mxchat_options', array());
3255 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
3256
3257 if ($show_pdf_button !== 'on') {
3258 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
3259 return;
3260 }
3261
3262 $file = $_FILES['pdf_file'];
3263 $session_id = sanitize_text_field($_POST['session_id']);
3264 $original_filename = sanitize_text_field($file['name']);
3265
3266 // SECURITY FIX: Verify session ownership before allowing upload
3267 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
3268 $session_owner = get_option("mxchat_session_owner_{$session_id}");
3269
3270 if ($session_owner && $session_owner !== $current_user_identifier) {
3271 wp_send_json_error(esc_html__('Unauthorized access.', 'mxchat'));
3272 return;
3273 }
3274
3275 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
3276 if ($file_type['type'] !== 'application/pdf') {
3277 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
3278 return;
3279 }
3280
3281 $upload_dir = wp_upload_dir();
3282
3283 // SECURITY FIX: Generate random filename without exposing session_id
3284 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
3285 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
3286 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
3287
3288 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
3289 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
3290 return;
3291 }
3292
3293 $this->clear_pdf_transients($session_id);
3294
3295 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3296 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
3297
3298 if ($embeddings === 'too_many_pages') {
3299 unlink($pdf_path);
3300 $error_message = sprintf(
3301 $this->options['pdf_intent_error_text'] ??
3302 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
3303 $max_pages
3304 );
3305 wp_send_json_error($error_message);
3306 return;
3307 }
3308
3309 if ($embeddings === false || empty($embeddings)) {
3310 unlink($pdf_path);
3311 $error_message = $this->options['pdf_intent_error_text'] ??
3312 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
3313 wp_send_json_error($error_message);
3314 return;
3315 }
3316
3317 if (!empty($embeddings)) {
3318 // Store the mapping between session and the random filename
3319 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
3320 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
3321 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
3322 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
3323
3324 $success_message = $this->options['pdf_intent_success_text'] ??
3325 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
3326
3327 wp_send_json_success([
3328 'message' => $success_message,
3329 'filename' => $original_filename
3330 ]);
3331 return;
3332 }
3333
3334 unlink($pdf_path);
3335 $error_message = $this->options['pdf_intent_error_text'] ??
3336 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
3337 wp_send_json_error($error_message);
3338 return;
3339 }
3340 public function handle_pdf_remove() {
3341 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3342
3343 if (empty($_POST['session_id'])) {
3344 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
3345 wp_die();
3346 }
3347
3348 $session_id = sanitize_text_field($_POST['session_id']);
3349 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
3350
3351 if ($pdf_path && file_exists($pdf_path)) {
3352 unlink($pdf_path);
3353 }
3354
3355 $this->clear_pdf_transients($session_id);
3356
3357 wp_send_json_success([
3358 'message' => esc_html__('PDF removed successfully.', 'mxchat')
3359 ]);
3360 wp_die();
3361 }
3362
3363
3364 function mxchat_fetch_new_messages() {
3365 $session_id = sanitize_text_field($_POST['session_id']);
3366 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
3367 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
3368 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
3369
3370 if (empty($session_id)) {
3371 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
3372 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
3373 wp_die();
3374 }
3375
3376 $history = get_option("mxchat_history_{$session_id}", []);
3377
3378 error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
3379 error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
3380 error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
3381 error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
3382
3383 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
3384 error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
3385
3386 // If persistence is enabled, show all new messages
3387 if ($persistence_enabled) {
3388 $has_id = !empty($message['id']);
3389 $is_agent = $message['role'] === 'agent';
3390
3391 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
3392 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
3393 $is_newer = true;
3394 } else {
3395 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
3396 }
3397
3398 error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
3399
3400 return $has_id && $is_newer && $is_agent;
3401 }
3402
3403 // If persistence is disabled, only show messages after initial timestamp
3404 return !empty($message['id']) &&
3405 $message['role'] === 'agent' &&
3406 $message['timestamp'] > $initial_timestamp;
3407 });
3408
3409 error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
3410
3411 // Include current chat mode so frontend can detect agent→AI transitions
3412 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
3413
3414 wp_send_json_success([
3415 'new_messages' => array_values($new_messages),
3416 'chat_mode' => $chat_mode
3417 ]);
3418 wp_die();
3419 }
3420 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
3421 // First check if live agents are available
3422 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
3423 if ($live_agent_available !== 'on') {
3424 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3425 $this->fallbackResponse = [
3426 'text' => $away_message,
3427 'html' => '',
3428 'images' => [],
3429 'chat_mode' => 'ai'
3430 ];
3431 wp_send_json([
3432 'text' => $away_message,
3433 'html' => '',
3434 'chat_mode' => 'ai',
3435 'session_id' => $session_id
3436 ]);
3437 wp_die();
3438 }
3439
3440 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3441
3442 if (empty($slack_bot_token)) {
3443 return false;
3444 }
3445
3446 // Check if channel already exists for this session
3447 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3448
3449 if (empty($channel_id)) {
3450 // Create new channel with session ID as name
3451 $channel_name = $this->generate_channel_name($session_id);
3452
3453 //error_log("Attempting to create channel: $channel_name");
3454
3455 $response = wp_remote_post('https://slack.com/api/conversations.create', [
3456 'headers' => [
3457 'Content-Type' => 'application/json',
3458 'Authorization' => 'Bearer ' . $slack_bot_token
3459 ],
3460 'body' => json_encode([
3461 'name' => $channel_name,
3462 'is_private' => false // Public channel - anyone in workspace can join
3463 ])
3464 ]);
3465
3466 if (!is_wp_error($response)) {
3467 $response_body = wp_remote_retrieve_body($response);
3468 $response_data = json_decode($response_body, true);
3469
3470 //error_log("Channel creation response: " . $response_body);
3471
3472 if (isset($response_data['ok']) && $response_data['ok']) {
3473 $channel_id = $response_data['channel']['id'];
3474 $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
3475 //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
3476 update_option("mxchat_channel_{$session_id}", $channel_id);
3477
3478 // Auto-invite agents to the channel
3479 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
3480
3481 if (!empty($agent_user_ids)) {
3482 // Parse user IDs (one per line)
3483 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
3484
3485 foreach ($user_ids as $user_id_to_invite) {
3486 //error_log("Inviting user to channel: $user_id_to_invite");
3487
3488 $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
3489 'headers' => [
3490 'Content-Type' => 'application/json',
3491 'Authorization' => 'Bearer ' . $slack_bot_token
3492 ],
3493 'body' => json_encode([
3494 'channel' => $channel_id,
3495 'users' => $user_id_to_invite
3496 ])
3497 ]);
3498
3499 if (!is_wp_error($invite_response)) {
3500 $invite_body = wp_remote_retrieve_body($invite_response);
3501 $invite_data = json_decode($invite_body, true);
3502 //error_log("Invite response for $user_id_to_invite: " . $invite_body);
3503
3504 if (isset($invite_data['ok']) && $invite_data['ok']) {
3505 //error_log("Successfully invited user $user_id_to_invite to channel");
3506 } else {
3507 //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
3508 }
3509 } else {
3510 //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
3511 }
3512 }
3513 } else {
3514 //error_log("No agent user IDs configured for auto-invite");
3515 }
3516 } else {
3517 //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
3518 }
3519 } else {
3520 //error_log("WP Error creating channel: " . $response->get_error_message());
3521 }
3522
3523 if (empty($channel_id)) {
3524 return false; // Failed to create channel
3525 }
3526 }
3527
3528 // Get recent chat history
3529 $history = get_option("mxchat_history_{$session_id}", []);
3530 $recent_history = array_slice($history, -5);
3531
3532 // Format conversation context
3533 $conversation_context = "";
3534 if (!empty($recent_history)) {
3535 $conversation_context = "*Recent Conversation:*\n";
3536 foreach ($recent_history as $hist_message) {
3537 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
3538 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
3539 }
3540 $conversation_context .= "\n";
3541 }
3542
3543 update_option("mxchat_mode_{$session_id}", 'agent');
3544
3545 // Send message to channel
3546 $channel_message = "🔔 *New Live Agent Request*\n\n";
3547 $channel_message .= "*Session ID:* `{$session_id}`\n";
3548 $channel_message .= "*User ID:* `{$user_id}`\n\n";
3549
3550 if (!empty($conversation_context)) {
3551 $channel_message .= $conversation_context;
3552 }
3553
3554 $channel_message .= "*Current Message:*\n{$message}\n\n";
3555 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
3556
3557 wp_remote_post('https://slack.com/api/chat.postMessage', [
3558 'headers' => [
3559 'Content-Type' => 'application/json',
3560 'Authorization' => 'Bearer ' . $slack_bot_token
3561 ],
3562 'body' => json_encode([
3563 'channel' => $channel_id,
3564 'text' => $channel_message,
3565 'mrkdwn' => true
3566 ])
3567 ]);
3568
3569 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
3570 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3571
3572 $this->fallbackResponse = [
3573 'text' => $success_message,
3574 'html' => '',
3575 'images' => [],
3576 'chat_mode' => 'agent'
3577 ];
3578
3579 wp_send_json([
3580 'success' => true,
3581 'text' => $success_message,
3582 'html' => '',
3583 'chat_mode' => 'agent',
3584 'session_id' => $session_id,
3585 'fallbackResponse' => $this->fallbackResponse
3586 ]);
3587 wp_die();
3588 }
3589
3590 private function generate_channel_name($session_id) {
3591 $email = null;
3592 $name = null;
3593
3594 // 1. First priority: Check if user is logged in and get their info
3595 if (is_user_logged_in()) {
3596 $current_user = wp_get_current_user();
3597 if (!empty($current_user->user_email)) {
3598 $email = $current_user->user_email;
3599 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
3600 }
3601 if (!empty($current_user->display_name)) {
3602 $name = $current_user->display_name;
3603 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
3604 }
3605 }
3606
3607 // 2. Second priority: Check for saved email/name from "require email to chat" option
3608 if (empty($email)) {
3609 $email_option_key = "mxchat_email_{$session_id}";
3610 $saved_email = get_option($email_option_key);
3611 if (!empty($saved_email)) {
3612 $email = $saved_email;
3613 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
3614 }
3615 }
3616
3617 if (empty($name)) {
3618 $name_option_key = "mxchat_name_{$session_id}";
3619 $saved_name = get_option($name_option_key);
3620 if (!empty($saved_name)) {
3621 $name = $saved_name;
3622 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
3623 }
3624 }
3625
3626 // 3. Third priority: Check existing chat transcript for email/name
3627 if (empty($email) || empty($name)) {
3628 global $wpdb;
3629 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3630 $existing_data = $wpdb->get_row($wpdb->prepare(
3631 "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",
3632 $session_id
3633 ));
3634
3635 if ($existing_data) {
3636 if (empty($email) && !empty($existing_data->user_email)) {
3637 $email = $existing_data->user_email;
3638 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
3639 }
3640 if (empty($name) && !empty($existing_data->user_name)) {
3641 $name = $existing_data->user_name;
3642 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
3643 }
3644 }
3645 }
3646
3647 // 4. Generate channel name based on priority: Name > Email > Session ID
3648 $channel_name = '';
3649
3650 if (!empty($name)) {
3651 // Convert name to valid Slack channel name
3652 $base_name = strtolower(trim($name));
3653 // Replace spaces and invalid characters
3654 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
3655 $base_name = preg_replace('/\s+/', '-', $base_name);
3656 $base_name = trim($base_name, '-');
3657
3658 // Get last 4 characters of session ID for uniqueness
3659 $session_suffix = substr($session_id, -4);
3660 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
3661
3662 // Slack channel names have a 21 character limit
3663 if (strlen($channel_name) > 21) {
3664 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
3665 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
3666 $truncated_name = substr($base_name, 0, $available_space);
3667 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
3668 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
3669 }
3670
3671 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
3672
3673 } elseif (!empty($email)) {
3674 // Convert email to valid Slack channel name (your existing logic)
3675 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
3676 // Remove any remaining invalid characters
3677 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
3678 // Ensure it doesn't end with a hyphen
3679 $channel_name = rtrim($channel_name, '-');
3680 // Slack channel names have a 21 character limit, so truncate if needed
3681 if (strlen($channel_name) > 21) {
3682 $channel_name = substr($channel_name, 0, 21);
3683 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
3684 }
3685
3686 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
3687
3688 } else {
3689 // Fallback to session ID if no name or email found
3690 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
3691 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
3692 }
3693
3694 // Final validation - ensure channel name meets Slack requirements
3695 if (strlen($channel_name) > 21) {
3696 $channel_name = substr($channel_name, 0, 21);
3697 $channel_name = rtrim($channel_name, '-');
3698 }
3699
3700 //error_log("[DEBUG] Generated channel name: {$channel_name}");
3701 return $channel_name;
3702 }
3703
3704 /**
3705 * Telegram Live Agent Handover
3706 * Creates a forum topic in the Telegram group and notifies agents
3707 */
3708 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
3709 // Check if Telegram agents are available
3710 $telegram_available = $this->options['telegram_status'] ?? 'off';
3711 if ($telegram_available !== 'on') {
3712 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3713 $this->fallbackResponse = [
3714 'text' => $away_message,
3715 'html' => '',
3716 'images' => [],
3717 'chat_mode' => 'ai'
3718 ];
3719 wp_send_json([
3720 'text' => $away_message,
3721 'html' => '',
3722 'chat_mode' => 'ai',
3723 'session_id' => $session_id
3724 ]);
3725 wp_die();
3726 }
3727
3728 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3729 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
3730
3731 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
3732 return false;
3733 }
3734
3735 // Check if topic already exists for this session
3736 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3737
3738 if (empty($topic_id)) {
3739 // Generate topic name
3740 $topic_name = $this->generate_telegram_topic_name($session_id);
3741
3742 // Random icon color (Telegram forum topic colors)
3743 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
3744 $icon_color = $icon_colors[array_rand($icon_colors)];
3745
3746 // Create forum topic
3747 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
3748 'headers' => ['Content-Type' => 'application/json'],
3749 'body' => json_encode([
3750 'chat_id' => $telegram_group_id,
3751 'name' => $topic_name,
3752 'icon_color' => $icon_color
3753 ])
3754 ]);
3755
3756 if (!is_wp_error($response)) {
3757 $response_body = wp_remote_retrieve_body($response);
3758 $response_data = json_decode($response_body, true);
3759
3760 if (isset($response_data['ok']) && $response_data['ok']) {
3761 $topic_id = $response_data['result']['message_thread_id'];
3762 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
3763 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
3764 }
3765 }
3766
3767 if (empty($topic_id)) {
3768 return false; // Failed to create topic
3769 }
3770 }
3771
3772 // Get recent chat history
3773 $history = get_option("mxchat_history_{$session_id}", []);
3774 $recent_history = array_slice($history, -5);
3775
3776 // Format conversation context for Telegram (HTML format)
3777 $conversation_context = "";
3778 if (!empty($recent_history)) {
3779 $conversation_context = "<b>Recent Conversation:</b>\n";
3780 foreach ($recent_history as $hist_message) {
3781 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
3782 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
3783 $conversation_context .= "{$role_display}: {$escaped_content}\n";
3784 }
3785 $conversation_context .= "\n";
3786 }
3787
3788 // Get user info
3789 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
3790 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
3791
3792 // Update session mode
3793 update_option("mxchat_mode_{$session_id}", 'agent');
3794
3795 // Send initial message to topic
3796 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3797 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
3798 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
3799 $topic_message .= "<b>User:</b> {$user_name}\n";
3800 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
3801
3802 if (!empty($conversation_context)) {
3803 $topic_message .= $conversation_context;
3804 }
3805
3806 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
3807 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
3808 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
3809
3810 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3811 'headers' => ['Content-Type' => 'application/json'],
3812 'body' => json_encode([
3813 'chat_id' => $telegram_group_id,
3814 'message_thread_id' => $topic_id,
3815 'text' => $topic_message,
3816 'parse_mode' => 'HTML'
3817 ])
3818 ]);
3819
3820 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
3821 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3822
3823 $this->fallbackResponse = [
3824 'text' => $success_message,
3825 'html' => '',
3826 'images' => [],
3827 'chat_mode' => 'agent'
3828 ];
3829
3830 wp_send_json([
3831 'success' => true,
3832 'text' => $success_message,
3833 'html' => '',
3834 'chat_mode' => 'agent',
3835 'session_id' => $session_id,
3836 'fallbackResponse' => $this->fallbackResponse
3837 ]);
3838 wp_die();
3839 }
3840
3841 /**
3842 * Generate topic name for Telegram forum
3843 */
3844 private function generate_telegram_topic_name($session_id) {
3845 $name = null;
3846 $email = null;
3847
3848 // Check logged in user
3849 if (is_user_logged_in()) {
3850 $current_user = wp_get_current_user();
3851 if (!empty($current_user->display_name)) {
3852 $name = $current_user->display_name;
3853 }
3854 if (!empty($current_user->user_email)) {
3855 $email = $current_user->user_email;
3856 }
3857 }
3858
3859 // Check session data
3860 if (empty($name)) {
3861 $name = get_option("mxchat_name_{$session_id}");
3862 }
3863 if (empty($email)) {
3864 $email = get_option("mxchat_email_{$session_id}");
3865 }
3866
3867 // Generate topic name
3868 $session_suffix = substr($session_id, -6);
3869
3870 if (!empty($name)) {
3871 // Clean name for topic (max 128 chars in Telegram)
3872 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
3873 $clean_name = trim($clean_name);
3874 if (strlen($clean_name) > 50) {
3875 $clean_name = substr($clean_name, 0, 50);
3876 }
3877 return "Chat - {$clean_name} ({$session_suffix})";
3878 } elseif (!empty($email)) {
3879 // Use email prefix
3880 $email_prefix = explode('@', $email)[0];
3881 if (strlen($email_prefix) > 30) {
3882 $email_prefix = substr($email_prefix, 0, 30);
3883 }
3884 return "Chat - {$email_prefix} ({$session_suffix})";
3885 }
3886
3887 return "Chat - {$session_suffix}";
3888 }
3889
3890 /**
3891 * Send user message to Telegram agent
3892 */
3893 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
3894 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3895 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3896 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3897
3898 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
3899 return false;
3900 }
3901
3902 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3903 $user_message = "👤 <b>User:</b> {$escaped_message}";
3904
3905 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3906 'headers' => ['Content-Type' => 'application/json'],
3907 'body' => json_encode([
3908 'chat_id' => $group_id,
3909 'message_thread_id' => $topic_id,
3910 'text' => $user_message,
3911 'parse_mode' => 'HTML'
3912 ])
3913 ]);
3914
3915 return !is_wp_error($response);
3916 }
3917
3918 /**
3919 * Handle incoming Telegram webhook
3920 */
3921 public function handle_telegram_webhook(WP_REST_Request $request) {
3922 $body = $request->get_body();
3923 $data = json_decode($body, true);
3924
3925 error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
3926
3927 // Handle message events from forum topics
3928 if (isset($data['message'])) {
3929 $message_data = $data['message'];
3930
3931 // Skip if not from a forum topic
3932 if (!isset($message_data['message_thread_id'])) {
3933 error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
3934 return new WP_REST_Response(['ok' => true]);
3935 }
3936
3937 // Skip bot messages
3938 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
3939 error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
3940 return new WP_REST_Response(['ok' => true]);
3941 }
3942
3943 $chat_id = $message_data['chat']['id'] ?? '';
3944 $topic_id = $message_data['message_thread_id'];
3945 $message_text = $message_data['text'] ?? '';
3946 $message_id = $message_data['message_id'] ?? '';
3947 $from = $message_data['from'] ?? [];
3948 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
3949 if (empty($agent_name)) {
3950 $agent_name = $from['username'] ?? 'Agent';
3951 }
3952
3953 error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
3954
3955 // Skip empty messages
3956 if (empty($message_text)) {
3957 error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
3958 return new WP_REST_Response(['ok' => true]);
3959 }
3960
3961 // Find session ID by topic ID - cast to string for comparison
3962 global $wpdb;
3963 $topic_id_str = strval($topic_id);
3964 $session_option = $wpdb->get_var(
3965 $wpdb->prepare(
3966 "SELECT option_name FROM {$wpdb->options}
3967 WHERE option_name LIKE %s
3968 AND option_value = %s",
3969 'mxchat_telegram_topic_%',
3970 $topic_id_str
3971 )
3972 );
3973
3974 error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
3975
3976 if ($session_option) {
3977 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
3978 error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
3979
3980 // Verify the group ID matches
3981 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3982 error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
3983
3984 if (strval($stored_group_id) != strval($chat_id)) {
3985 error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
3986 return new WP_REST_Response(['ok' => true]);
3987 }
3988
3989 // Check for closure commands
3990 $lower_text = strtolower(trim($message_text));
3991 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
3992 error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
3993 // End the live agent session
3994 update_option("mxchat_mode_{$session_id}", 'ai');
3995
3996 // Save disconnect message
3997 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
3998 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
3999
4000 // Notify in Telegram
4001 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4002 if (!empty($telegram_bot_token)) {
4003 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4004 'headers' => ['Content-Type' => 'application/json'],
4005 'body' => json_encode([
4006 'chat_id' => $chat_id,
4007 'message_thread_id' => $topic_id,
4008 'text' => "✅ Session closed. User returned to AI chatbot.",
4009 'parse_mode' => 'HTML'
4010 ])
4011 ]);
4012
4013 // Optionally close the topic
4014 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
4015 'headers' => ['Content-Type' => 'application/json'],
4016 'body' => json_encode([
4017 'chat_id' => $chat_id,
4018 'message_thread_id' => $topic_id
4019 ])
4020 ]);
4021 }
4022
4023 return new WP_REST_Response(['ok' => true]);
4024 }
4025
4026 // Deduplicate messages
4027 $message_key = md5($session_id . $message_id . $message_text);
4028 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
4029
4030 if (in_array($message_key, $processed_messages)) {
4031 error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
4032 return new WP_REST_Response(['ok' => true]);
4033 }
4034
4035 $processed_messages[] = $message_key;
4036 if (count($processed_messages) > 50) {
4037 $processed_messages = array_slice($processed_messages, -50);
4038 }
4039 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4040
4041 // Save the agent message - format with agent name prefix for proper parsing
4042 $formatted_message = "Agent: {$agent_name} - {$message_text}";
4043 error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
4044
4045 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
4046
4047 // Verify the message was saved to history
4048 $history = get_option("mxchat_history_{$session_id}", []);
4049 $last_message = end($history);
4050 error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
4051
4052 // Send confirmation back to Telegram
4053 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4054 if (!empty($telegram_bot_token)) {
4055 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
4056 if (!get_transient($confirm_key)) {
4057 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4058 'headers' => ['Content-Type' => 'application/json'],
4059 'body' => json_encode([
4060 'chat_id' => $chat_id,
4061 'message_thread_id' => $topic_id,
4062 'text' => "✅ <i>Message sent to user</i>",
4063 'parse_mode' => 'HTML',
4064 'reply_to_message_id' => $message_id
4065 ])
4066 ]);
4067 set_transient($confirm_key, true, 300);
4068 }
4069 }
4070 } else {
4071 error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
4072 }
4073 } else {
4074 error_log('[MxChat Telegram DEBUG] No message in webhook data');
4075 }
4076
4077 return new WP_REST_Response(['ok' => true]);
4078 }
4079
4080 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
4081 // Check if this is a Telegram agent session
4082 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4083 if (!empty($telegram_topic_id)) {
4084 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
4085 }
4086
4087 // Otherwise, try Slack
4088 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4089 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4090
4091 if (empty($slack_bot_token) || empty($channel_id)) {
4092 return false;
4093 }
4094
4095 $user_message = "💬 *User:* {$message}";
4096
4097 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4098 'headers' => [
4099 'Content-Type' => 'application/json',
4100 'Authorization' => 'Bearer ' . $slack_bot_token
4101 ],
4102 'body' => json_encode([
4103 'channel' => $channel_id,
4104 'text' => $user_message,
4105 'mrkdwn' => true
4106 ])
4107 ]);
4108
4109 return !is_wp_error($response);
4110 }
4111 public function handle_slack_interaction(WP_REST_Request $request) {
4112 //error_log('Received Slack interaction');
4113
4114 $payload = json_decode($request->get_param('payload'), true);
4115 //error_log('Payload: ' . print_r($payload, true));
4116
4117 // Handle button click
4118 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
4119 $session_id = $payload['actions'][0]['value'];
4120 $trigger_id = $payload['trigger_id'];
4121
4122 // Get Bot Token from settings
4123 $slack_token = $this->options['live_agent_bot_token'] ?? '';
4124
4125 if (empty($slack_token)) {
4126 //error_log('Slack Bot Token not configured');
4127 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
4128 }
4129 $response = wp_remote_post('https://slack.com/api/views.open', [
4130 'headers' => [
4131 'Content-Type' => 'application/json',
4132 'Authorization' => 'Bearer ' . $slack_token
4133 ],
4134 'body' => json_encode([
4135 'trigger_id' => $trigger_id,
4136 'view' => [
4137 'type' => 'modal',
4138 'callback_id' => 'reply_modal',
4139 'title' => [
4140 'type' => 'plain_text',
4141 'text' => __('Reply to User', 'mxchat')
4142 ],
4143 'submit' => [
4144 'type' => 'plain_text',
4145 'text' => __('Send', 'mxchat')
4146 ],
4147 'close' => [
4148 'type' => 'plain_text',
4149 'text' => __('Cancel', 'mxchat')
4150 ],
4151 'blocks' => [
4152 [
4153 'type' => 'input',
4154 'block_id' => 'reply_block',
4155 'label' => [
4156 'type' => 'plain_text',
4157 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
4158 ],
4159 'element' => [
4160 'type' => 'plain_text_input',
4161 'action_id' => 'message',
4162 'multiline' => true,
4163 'placeholder' => [
4164 'type' => 'plain_text',
4165 'text' => __('Type your message here...', 'mxchat')
4166 ]
4167 ]
4168 ]
4169 ],
4170 'private_metadata' => $session_id
4171 ]
4172 ])
4173 ]);
4174
4175 //error_log('Views.open response: ' . print_r($response, true));
4176
4177 // Return immediate acknowledgment
4178 return new WP_REST_Response(['ok' => true]);
4179 }
4180
4181 // Handle modal submission
4182 // Handle modal submission
4183 if ($payload['type'] === 'view_submission') {
4184 $session_id = $payload['view']['private_metadata'];
4185 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
4186
4187 // Save the message (keep the message_id but don't include in response)
4188 $this->mxchat_save_chat_message($session_id, 'agent', $message);
4189
4190 // Keep the original response format for Slack
4191 return new WP_REST_Response([
4192 'response_action' => 'clear'
4193 ]);
4194 }
4195
4196 // Default acknowledgment
4197 return new WP_REST_Response(['ok' => true]);
4198 }
4199 public function mxchat_handle_agent_response(WP_REST_Request $request) {
4200 //error_log('Received agent response request');
4201 //error_log('Request data: ' . print_r($request->get_params(), true));
4202 // //error_log('Raw body: ' . file_get_contents('php://input'));
4203
4204 // Get the data from Slack's slash command format
4205 $command_text = $request->get_param('text');
4206 // //error_log('Command text: ' . $command_text);
4207
4208 if (empty($command_text)) {
4209 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
4210 return new WP_REST_Response([
4211 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
4212 ], 400);
4213 }
4214
4215 // Split the command text into session_id and message
4216 $parts = explode(' ', $command_text, 2);
4217 if (count($parts) !== 2) {
4218 //error_log('Agent response error: Invalid command format');
4219 return new WP_REST_Response([
4220 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
4221 ], 400);
4222 }
4223
4224 $session_id = sanitize_text_field($parts[0]);
4225 $message = sanitize_text_field($parts[1]);
4226
4227 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
4228
4229 // Save the message
4230 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
4231
4232 if (!$message_id) {
4233 // //error_log('Failed to save agent message');
4234 return new WP_REST_Response([
4235 'error' => esc_html__('Failed to save message', 'mxchat')
4236 ], 500);
4237 }
4238
4239 // Return success response in Slack's expected format
4240 return new WP_REST_Response([
4241 'response_type' => 'in_channel',
4242 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
4243 ], 200);
4244 }
4245 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
4246 // Update mode to AI
4247 update_option("mxchat_mode_{$session_id}", 'ai');
4248
4249 // Clear any existing PDF context to start fresh
4250 $this->clear_pdf_transients($session_id);
4251
4252 // Set the response with explicit chat_mode
4253 $this->fallbackResponse = [
4254 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
4255 'html' => '',
4256 'images' => [],
4257 'chat_mode' => 'ai' // Ensure this is set
4258 ];
4259
4260 // Return the complete response array instead of just true
4261 return $this->fallbackResponse;
4262 }
4263
4264 public function handle_slack_messages(WP_REST_Request $request) {
4265 // Log the incoming request for debugging
4266 //error_log('Slack events request received: ' . $request->get_body());
4267
4268 $body = $request->get_body();
4269 $data = json_decode($body, true);
4270
4271 // Handle Slack URL verification
4272 if (isset($data['type']) && $data['type'] === 'url_verification') {
4273 //error_log('Slack URL verification challenge: ' . $data['challenge']);
4274 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
4275 }
4276
4277 // IMPORTANT: Handle Slack's event deduplication
4278 if (isset($data['event_id'])) {
4279 $event_id = $data['event_id'];
4280 $processed_events = get_transient('mxchat_slack_events') ?: [];
4281
4282 // Check if we've already processed this event
4283 if (in_array($event_id, $processed_events)) {
4284 //error_log("Duplicate event detected: $event_id");
4285 return new WP_REST_Response(['ok' => true]);
4286 }
4287
4288 // Add this event to processed list
4289 $processed_events[] = $event_id;
4290 // Keep only last 100 events to prevent memory issues
4291 if (count($processed_events) > 100) {
4292 $processed_events = array_slice($processed_events, -100);
4293 }
4294 // Store for 1 hour
4295 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
4296 }
4297
4298 // Handle message events
4299 if (isset($data['event']) && $data['event']['type'] === 'message') {
4300 $event = $data['event'];
4301
4302 // Skip bot messages and messages with subtypes (like bot_message)
4303 if (isset($event['bot_id']) || isset($event['subtype'])) {
4304 return new WP_REST_Response(['ok' => true]);
4305 }
4306
4307 // Additional check: Skip if this is a threaded reply to our confirmation
4308 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
4309 return new WP_REST_Response(['ok' => true]);
4310 }
4311
4312 $channel_id = $event['channel'];
4313 $message_text = $event['text'] ?? '';
4314 $message_ts = $event['ts'] ?? '';
4315
4316 // Find session ID by looking for matching channel
4317 global $wpdb;
4318 $session_option = $wpdb->get_var(
4319 $wpdb->prepare(
4320 "SELECT option_name FROM {$wpdb->options}
4321 WHERE option_name LIKE 'mxchat_channel_%'
4322 AND option_value = %s",
4323 $channel_id
4324 )
4325 );
4326
4327 if ($session_option) {
4328 $session_id = str_replace('mxchat_channel_', '', $session_option);
4329
4330 // Create a unique key for this specific message
4331 $message_key = md5($session_id . $message_ts . $message_text);
4332 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
4333
4334 // Check if we've already processed this exact message
4335 if (in_array($message_key, $processed_messages)) {
4336 //error_log("Duplicate message detected for session $session_id");
4337 return new WP_REST_Response(['ok' => true]);
4338 }
4339
4340 // Add to processed messages
4341 $processed_messages[] = $message_key;
4342 // Keep only last 50 messages per session
4343 if (count($processed_messages) > 50) {
4344 $processed_messages = array_slice($processed_messages, -50);
4345 }
4346 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4347
4348 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4349
4350 // Handle agent ending the chat — transfer back to AI
4351 // Format: "!endchat" or "!endchat <custom message to user>"
4352 if (preg_match('/^!endchat\b/i', trim($message_text))) {
4353 update_option("mxchat_mode_{$session_id}", 'ai');
4354
4355 // Extract custom message after !endchat, or use empty string
4356 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
4357
4358 // Send the agent's custom farewell message if provided
4359 if (!empty($custom_message)) {
4360 $this->mxchat_save_chat_message($session_id, 'agent', $custom_message);
4361 }
4362
4363 // Confirm in Slack channel
4364 if (!empty($slack_bot_token)) {
4365 wp_remote_post('https://slack.com/api/chat.postMessage', [
4366 'headers' => [
4367 'Content-Type' => 'application/json',
4368 'Authorization' => 'Bearer ' . $slack_bot_token
4369 ],
4370 'body' => json_encode([
4371 'channel' => $channel_id,
4372 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
4373 'mrkdwn' => true
4374 ])
4375 ]);
4376 }
4377
4378 return new WP_REST_Response(['ok' => true]);
4379 }
4380
4381 // Save the agent message
4382 $this->mxchat_save_chat_message($session_id, 'agent', $message_text);
4383
4384 // Send confirmation back to Slack (only once)
4385 if (!empty($slack_bot_token)) {
4386 // Use a transient to prevent duplicate confirmations
4387 $confirm_key = 'mxchat_confirm_' . $message_key;
4388 if (!get_transient($confirm_key)) {
4389 wp_remote_post('https://slack.com/api/chat.postMessage', [
4390 'headers' => [
4391 'Content-Type' => 'application/json',
4392 'Authorization' => 'Bearer ' . $slack_bot_token
4393 ],
4394 'body' => json_encode([
4395 'channel' => $channel_id,
4396 'text' => "✅ _Message sent to user_",
4397 'thread_ts' => $event['ts'] // Reply in thread
4398 ])
4399 ]);
4400 // Set transient to prevent duplicate confirmations
4401 set_transient($confirm_key, true, 300); // 5 minutes
4402 }
4403 }
4404 }
4405 }
4406
4407 return new WP_REST_Response(['ok' => true]);
4408 }
4409
4410 // For the word upload handler
4411 public function mxchat_handle_word_upload() {
4412 // Delegate to word handler
4413 $this->word_handler->mxchat_handle_word_upload();
4414 }
4415
4416 // For the word removal handler
4417 public function mxchat_handle_word_remove() {
4418 // Delegate to word handler
4419 $this->word_handler->mxchat_handle_word_remove();
4420 }
4421
4422 // For the word status check
4423 public function mxchat_check_word_status() {
4424 // Delegate to word handler
4425 $this->word_handler->mxchat_check_word_status();
4426 }
4427
4428
4429 private function mxchat_get_user_identifier() {
4430 return MxChat_User::mxchat_get_user_identifier();
4431 }
4432
4433 private function mxchat_generate_embedding($text, $api_key) {
4434 try {
4435 // Get options and selected model
4436 $options = get_option('mxchat_options');
4437 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4438
4439 // Determine endpoint and API key based on model
4440 if (strpos($selected_model, 'voyage') === 0) {
4441 $endpoint = 'https://api.voyageai.com/v1/embeddings';
4442 $api_key = $options['voyage_api_key'] ?? '';
4443
4444 // Check if Voyage API key is missing
4445 if (empty($api_key)) {
4446 //error_log('Voyage API key is missing');
4447 return [
4448 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
4449 'error_code' => 'missing_voyage_api_key'
4450 ];
4451 }
4452 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4453 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4454 $api_key = $options['gemini_api_key'] ?? '';
4455
4456 // Check if Gemini API key is missing
4457 if (empty($api_key)) {
4458 //error_log('Gemini API key is missing');
4459 return [
4460 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
4461 'error_code' => 'missing_gemini_api_key'
4462 ];
4463 }
4464 } else {
4465 $endpoint = 'https://api.openai.com/v1/embeddings';
4466 // Use the passed API key for OpenAI
4467
4468 // Check if OpenAI API key is missing
4469 if (empty($api_key)) {
4470 //error_log('OpenAI API key is missing');
4471 return [
4472 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
4473 'error_code' => 'missing_openai_api_key'
4474 ];
4475 }
4476 }
4477
4478 // Check if text is empty
4479 if (empty($text)) {
4480 //error_log('Empty text provided for embedding generation');
4481 return [
4482 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
4483 'error_code' => 'empty_embedding_text'
4484 ];
4485 }
4486
4487 // Prepare request body based on provider
4488 if (strpos($selected_model, 'gemini-embedding') === 0) {
4489 // Gemini API format
4490 $request_body = [
4491 'model' => 'models/' . $selected_model,
4492 'content' => [
4493 'parts' => [
4494 ['text' => $text]
4495 ]
4496 ],
4497 'outputDimensionality' => 1536
4498 ];
4499
4500 // Prepare headers for Gemini (API key as query parameter)
4501 $endpoint .= '?key=' . $api_key;
4502 $headers = [
4503 'Content-Type' => 'application/json'
4504 ];
4505 } else {
4506 // OpenAI/Voyage API format
4507 $request_body = [
4508 'input' => $text,
4509 'model' => $selected_model
4510 ];
4511
4512 // Add output_dimension for voyage-3-large
4513 if ($selected_model === 'voyage-3-large') {
4514 $request_body['output_dimension'] = 2048;
4515 }
4516
4517 // Prepare headers for OpenAI/Voyage
4518 $headers = [
4519 'Content-Type' => 'application/json',
4520 'Authorization' => 'Bearer ' . $api_key
4521 ];
4522 }
4523
4524 // Prepare request arguments
4525 $args = [
4526 'body' => wp_json_encode($request_body),
4527 'headers' => $headers,
4528 'timeout' => 60,
4529 'redirection' => 5,
4530 'blocking' => true,
4531 'httpversion' => '1.0',
4532 'sslverify' => true,
4533 ];
4534
4535 // Make the request
4536 $response = wp_remote_post($endpoint, $args);
4537
4538 // Handle WordPress errors
4539 if (is_wp_error($response)) {
4540 $error_message = $response->get_error_message();
4541 //error_log('Embedding Generation Error: ' . $error_message);
4542 return [
4543 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
4544 'error_code' => 'embedding_connection_error'
4545 ];
4546 }
4547
4548 // Check HTTP status code
4549 $status_code = wp_remote_retrieve_response_code($response);
4550 if ($status_code !== 200) {
4551 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4552
4553 $error_message = isset($response_body['error']['message'])
4554 ? $response_body['error']['message']
4555 : 'HTTP Error ' . $status_code;
4556
4557 $error_type = isset($response_body['error']['type'])
4558 ? $response_body['error']['type']
4559 : 'unknown';
4560
4561 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
4562
4563 // Handle specific error types
4564 switch ($error_type) {
4565 case 'invalid_request_error':
4566 if (strpos($error_message, 'API key') !== false) {
4567 return [
4568 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
4569 'error_code' => 'embedding_invalid_api_key'
4570 ];
4571 }
4572 break;
4573
4574 case 'authentication_error':
4575 return [
4576 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
4577 'error_code' => 'embedding_auth_error'
4578 ];
4579
4580 case 'rate_limit_exceeded':
4581 return [
4582 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
4583 'error_code' => 'embedding_rate_limit'
4584 ];
4585
4586 case 'quota_exceeded':
4587 return [
4588 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
4589 'error_code' => 'embedding_quota_exceeded'
4590 ];
4591 }
4592
4593 // Generic error fallback
4594 return [
4595 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
4596 'error_code' => 'embedding_api_error',
4597 'status_code' => $status_code
4598 ];
4599 }
4600
4601 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4602
4603 // Handle different response formats based on provider
4604 if (strpos($selected_model, 'gemini-embedding') === 0) {
4605 // Gemini API response format
4606 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
4607 return $response_body['embedding']['values'];
4608 } else {
4609 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
4610 return [
4611 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
4612 'error_code' => 'invalid_gemini_embedding_response'
4613 ];
4614 }
4615 } else {
4616 // OpenAI/Voyage API response format
4617 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
4618 return $response_body['data'][0]['embedding'];
4619 } else {
4620 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
4621 return [
4622 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
4623 'error_code' => 'invalid_embedding_response'
4624 ];
4625 }
4626 }
4627 } catch (Exception $e) {
4628 //error_log('Embedding Exception: ' . $e->getMessage());
4629 return [
4630 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
4631 'error_code' => 'embedding_exception'
4632 ];
4633 }
4634 }
4635
4636
4637 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
4638 error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
4639
4640 // Check for OpenAI Vector Store first (takes priority when enabled)
4641 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
4642
4643 if ($bot_vectorstore_config['use_vectorstore']) {
4644 // Get current model to verify it's an OpenAI model
4645 $bot_options = $this->get_bot_options($bot_id);
4646 $mxchat_options = get_option('mxchat_options', array());
4647 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
4648 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
4649
4650 if ($this->is_openai_chat_model($selected_model)) {
4651 error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
4652 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
4653 } else {
4654 error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
4655 }
4656 }
4657
4658 // Get bot-specific Pinecone configuration
4659 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4660
4661 // Debug: Log the Pinecone configuration
4662 error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
4663 error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
4664 error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
4665 error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
4666 error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
4667
4668 // Determine whether to use Pinecone based on bot configuration
4669 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
4670
4671 error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
4672
4673 if ($use_pinecone) {
4674 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
4675 } else {
4676 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
4677 }
4678 }
4679
4680 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
4681 global $wpdb;
4682 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
4683 $cache_key = 'mxchat_system_prompt_embeddings_' . $bot_id;
4684 $batch_size = 500;
4685
4686 // Initialize similarity analysis storage
4687 $this->last_similarity_analysis = [
4688 'knowledge_base_type' => 'WordPress Database',
4689 'bot_id' => $bot_id,
4690 'top_matches' => [],
4691 'threshold_used' => 0,
4692 'total_checked' => 0
4693 ];
4694
4695 // NEW: Initialize valid URLs array
4696 $valid_urls = [];
4697
4698 // Get bot-specific options for similarity threshold
4699 $bot_options = $this->get_bot_options($bot_id);
4700 $current_options = !empty($bot_options) ? $bot_options : $this->options;
4701
4702 // Retrieve embeddings from cache or database
4703 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
4704 if ($embeddings === false) {
4705 // Cache miss - load embeddings from database WITH CONTENT and ROLE RESTRICTION for testing
4706 $embeddings = [];
4707 $offset = 0;
4708
4709 do {
4710 // Add bot_id filter if not default and if bot_metadata column exists
4711 $bot_filter = '';
4712 if ($bot_id !== 'default') {
4713 // Check if bot_metadata column exists
4714 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
4715 if ($column_exists) {
4716 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
4717 }
4718 }
4719
4720 $query = $wpdb->prepare(
4721 "SELECT id, embedding_vector, article_content, source_url, role_restriction
4722 FROM {$system_prompt_table}
4723 WHERE 1=1 {$bot_filter}
4724 LIMIT %d OFFSET %d",
4725 $batch_size,
4726 $offset
4727 );
4728
4729 $batch = $wpdb->get_results($query);
4730 if (empty($batch)) {
4731 break;
4732 }
4733
4734 $embeddings = array_merge($embeddings, $batch);
4735 $offset += $batch_size;
4736 unset($batch);
4737 } while (true);
4738
4739 if (empty($embeddings)) {
4740 // Store empty array for valid URLs since no content found
4741 $this->current_valid_urls = [];
4742 return '';
4743 }
4744
4745 // Cache embeddings for future use (but note: this now includes content and role restrictions)
4746 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
4747 }
4748
4749 // Get knowledge manager instance for role checking
4750 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4751
4752 // Get base similarity threshold from bot options or default options
4753 $similarity_threshold = isset($current_options['similarity_threshold'])
4754 ? ((int) $current_options['similarity_threshold']) / 100
4755 : 0.35;
4756
4757 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
4758
4759 // Calculate similarities and build results array
4760 $all_similarities = [];
4761 $url_groups = array(); // NEW: Group by source_url for chunk reassembly
4762
4763 foreach ($embeddings as $embedding) {
4764 $database_embedding = $embedding->embedding_vector
4765 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
4766 : null;
4767
4768 if (is_array($database_embedding) && is_array($user_embedding)) {
4769 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
4770
4771 // Check role access
4772 $role_restriction = $embedding->role_restriction ?? 'public';
4773 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
4774
4775 // Store ALL similarities for testing (top 10)
4776 $source_display = '';
4777 $source_url = $embedding->source_url ?? '';
4778 if (!empty($source_url) && $source_url !== '#') {
4779 $source_display = $source_url;
4780 } else {
4781 $content_preview = strip_tags($embedding->article_content ?? '');
4782 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
4783 $source_display = substr(trim($content_preview), 0, 50) . '...';
4784 }
4785
4786 // Parse chunk metadata for display
4787 $article_content_for_parse = $embedding->article_content ?? '';
4788 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
4789 $is_chunk = $parsed_for_display['is_chunked'];
4790 $chunk_meta = $parsed_for_display['metadata'];
4791
4792 $all_similarities[] = [
4793 'document_id' => $embedding->id,
4794 'similarity' => $similarity,
4795 'similarity_percentage' => round($similarity * 100, 2),
4796 'above_threshold' => $similarity >= $similarity_threshold,
4797 'source_display' => $source_display,
4798 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
4799 'used_for_context' => false,
4800 'role_restriction' => $role_restriction,
4801 'has_access' => $has_access,
4802 'filtered_out' => !$has_access,
4803 'is_chunk' => $is_chunk,
4804 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
4805 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
4806 ];
4807
4808 // Only consider results above threshold AND with access for content retrieval
4809 if ($similarity >= $similarity_threshold && $has_access) {
4810 // Parse chunk metadata if present
4811 $article_content = $embedding->article_content ?? '';
4812 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
4813 $is_chunked = $parsed['is_chunked'];
4814 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
4815 $text_content = $parsed['text'];
4816
4817 // Use a unique key for manual entries without a source URL
4818 $group_key = !empty($source_url) ? $source_url : '_manual_' . $embedding->id;
4819
4820 // Group by source URL (or unique key for manual entries)
4821 if (!isset($url_groups[$group_key])) {
4822 $url_groups[$group_key] = array(
4823 'source_url' => $source_url,
4824 'best_score' => 0,
4825 'is_chunked' => $is_chunked,
4826 'chunks' => array(),
4827 'single_text' => '',
4828 'single_id' => null
4829 );
4830 }
4831
4832 // Track best score for this group
4833 if ($similarity > $url_groups[$group_key]['best_score']) {
4834 $url_groups[$group_key]['best_score'] = $similarity;
4835 }
4836
4837 // Store chunk info or single text
4838 if ($is_chunked) {
4839 $url_groups[$group_key]['is_chunked'] = true;
4840 $url_groups[$group_key]['chunks'][] = array(
4841 'id' => $embedding->id,
4842 'score' => $similarity,
4843 'chunk_index' => $chunk_index,
4844 'text' => $text_content
4845 );
4846 } else {
4847 $url_groups[$group_key]['single_text'] = $text_content;
4848 $url_groups[$group_key]['single_id'] = $embedding->id;
4849 }
4850 }
4851 }
4852
4853 unset($database_embedding);
4854 }
4855
4856 // Sort ALL similarities for testing display (highest first)
4857 usort($all_similarities, function ($a, $b) {
4858 return $b['similarity'] <=> $a['similarity'];
4859 });
4860
4861 // Sort URL groups by best score (highest first)
4862 uasort($url_groups, function($a, $b) {
4863 return $b['best_score'] <=> $a['best_score'];
4864 });
4865
4866 // Get RAG sources limit from options (default 6, min 3, max 10)
4867 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 6;
4868 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
4869 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
4870
4871 // Take top N unique URLs based on user setting
4872 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
4873
4874 // Track which document IDs are used for context
4875 $used_document_ids = [];
4876 foreach ($top_urls as $group) {
4877 if ($group['is_chunked']) {
4878 foreach ($group['chunks'] as $chunk) {
4879 $used_document_ids[] = $chunk['id'];
4880 }
4881 } elseif ($group['single_id']) {
4882 $used_document_ids[] = $group['single_id'];
4883 }
4884 }
4885
4886 // Update the all_similarities array to mark which were actually used
4887 foreach ($all_similarities as &$similarity_item) {
4888 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
4889 }
4890
4891 // Store top 10 for testing panel
4892 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
4893 $this->last_similarity_analysis['total_checked'] = count($embeddings);
4894
4895 // Initialize final content
4896 $content = '';
4897 $matches_used = 0;
4898 $total_chunks_used = 0;
4899 $max_total_chunks = 30; // Hard cap on total chunks to prevent excessive token usage
4900
4901 // Check if citation links are enabled (default to 'on' for backwards compatibility)
4902 // Use fresh options to ensure we get the latest setting value
4903 $fresh_options = get_option('mxchat_options', []);
4904 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
4905
4906 // Build content from top sources
4907 foreach ($top_urls as $group_key => $group) {
4908 $source_url = $group['source_url']; // Use actual source_url, not the group key
4909
4910 // Stop if we've hit the total chunk limit
4911 if ($total_chunks_used >= $max_total_chunks) {
4912 break;
4913 }
4914
4915 $full_text = '';
4916 $chunks_in_this_source = 1; // Default for non-chunked content
4917
4918 if ($group['is_chunked']) {
4919 // Calculate how many chunks we can still use
4920 $chunks_remaining = $max_total_chunks - $total_chunks_used;
4921
4922 // Fetch chunks for this URL with limit
4923 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
4924
4925 // If fetching all chunks fails, fall back to matched chunks
4926 if (empty($full_text)) {
4927 // Sort matched chunks by index and concatenate
4928 usort($group['chunks'], function($a, $b) {
4929 return $a['chunk_index'] <=> $b['chunk_index'];
4930 });
4931
4932 $chunk_texts = array();
4933 $chunks_in_this_source = 0;
4934 foreach ($group['chunks'] as $chunk) {
4935 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
4936 break;
4937 }
4938 $chunk_texts[] = $chunk['text'];
4939 $chunks_in_this_source++;
4940 }
4941 $full_text = implode("\n\n", $chunk_texts);
4942 }
4943 } else {
4944 $full_text = $group['single_text'];
4945 $chunks_in_this_source = 1;
4946 }
4947
4948 if (!empty($full_text)) {
4949 // Strip URLs from content if citation links are disabled
4950 if (!$citation_links_enabled) {
4951 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
4952 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
4953 }
4954
4955 // Use numbered reference for URL-based entries, plain info label for manual entries
4956 if (!empty($source_url) && $source_url !== '#') {
4957 $matches_used++;
4958 $content .= "## Reference " . $matches_used . " ##\n";
4959 $content .= $full_text . "\n\n";
4960
4961 // Only include citation URLs if citation links are enabled
4962 if ($citation_links_enabled) {
4963 $valid_urls[] = $source_url;
4964 $content .= "URL: " . $source_url . "\n\n";
4965 }
4966 } else {
4967 // Manual entry — no reference number, no citation
4968 $content .= "## Information ##\n";
4969 $content .= $full_text . "\n\n";
4970 }
4971
4972 // Extract any URLs from the text content itself (only if citation links enabled)
4973 if ($citation_links_enabled) {
4974 preg_match_all(
4975 '#\bhttps?://[^\s<>"\']+#i',
4976 $full_text,
4977 $content_urls
4978 );
4979 if (!empty($content_urls[0])) {
4980 $valid_urls = array_merge($valid_urls, $content_urls[0]);
4981 }
4982 }
4983
4984 $total_chunks_used += $chunks_in_this_source;
4985 }
4986 }
4987
4988 // NEW: Store unique valid URLs for validation
4989 $this->current_valid_urls = array_unique($valid_urls);
4990
4991 // Add response guidelines
4992 if (empty($top_urls)) {
4993 $content = "No reference information was found for this query.\n\n";
4994 } else {
4995 // Build response guidelines based on citation links setting
4996 $content .= "\n## Response Guidelines ##\n" .
4997 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
4998 "Be conversational and friendly, but never mention your knowledge base or training data. " .
4999 "If you don't have specific information or are uncertain about any details, it's always " .
5000 "better to honestly say you don't know rather than making up or guessing at answers. " .
5001 "When information is incomplete, let them know you are unsure.\n\n";
5002
5003 // Only add hyperlink instructions if citation links are enabled
5004 if ($citation_links_enabled) {
5005 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5006 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5007 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5008 } else {
5009 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5010 "Simply provide helpful answers based on the reference information without citing sources.";
5011 }
5012 }
5013
5014 return trim($content);
5015 }
5016
5017 /**
5018 * Fetch and reassemble chunks for a URL from WordPress database
5019 *
5020 * @param string $source_url The source URL to fetch chunks for
5021 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
5022 * @param int &$chunk_count Reference to store the actual number of chunks returned
5023 * @return string Reassembled content from chunks
5024 */
5025 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
5026 global $wpdb;
5027 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
5028
5029 // Fetch all rows with this source_url
5030 $rows = $wpdb->get_results($wpdb->prepare(
5031 "SELECT article_content FROM {$table}
5032 WHERE source_url = %s
5033 ORDER BY id ASC",
5034 $source_url
5035 ));
5036
5037 if (empty($rows)) {
5038 $chunk_count = 0;
5039 return '';
5040 }
5041
5042 // Parse and sort chunks by index
5043 $chunks = array();
5044 foreach ($rows as $row) {
5045 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
5046
5047 if ($parsed['is_chunked']) {
5048 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
5049 $chunks[$chunk_index] = $parsed['text'];
5050 } else {
5051 // Non-chunked content - just return it
5052 $chunks[] = $parsed['text'];
5053 }
5054 }
5055
5056 // Sort by chunk index
5057 ksort($chunks);
5058
5059 // Apply chunk limit if specified
5060 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5061 $chunks = array_slice($chunks, 0, $max_chunks, true);
5062 }
5063
5064 // Store actual chunk count
5065 $chunk_count = count($chunks);
5066
5067 // Reassemble content
5068 return implode("\n\n", $chunks);
5069 }
5070
5071 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
5072 global $wpdb;
5073
5074 error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
5075 error_log(" - bot_id: " . $bot_id);
5076 error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
5077 error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
5078
5079 // Use bot-specific config or fall back to default
5080 if ($bot_config === null) {
5081 $bot_config = $this->get_bot_pinecone_config($bot_id);
5082 }
5083
5084 $api_key = $bot_config['api_key'] ?? '';
5085 $host = $bot_config['host'] ?? '';
5086 $namespace = $bot_config['namespace'] ?? '';
5087
5088 error_log("MXCHAT DEBUG: Pinecone query parameters:");
5089 error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
5090 error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
5091 error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
5092
5093 // Initialize similarity analysis storage
5094 $this->last_similarity_analysis = [
5095 'knowledge_base_type' => 'Pinecone',
5096 'bot_id' => $bot_id,
5097 'namespace' => $namespace,
5098 'top_matches' => [],
5099 'threshold_used' => 0,
5100 'total_checked' => 0
5101 ];
5102
5103 // NEW: Initialize valid URLs array
5104 $valid_urls = [];
5105
5106 if (empty($host) || empty($api_key)) {
5107 error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
5108 error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
5109 error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
5110 // Store empty array for valid URLs since we can't proceed
5111 $this->current_valid_urls = [];
5112 return '';
5113 }
5114
5115 // Get knowledge manager instance for role checking
5116 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5117
5118 // Get the similarity threshold from the bot options or main options
5119 $bot_options = $this->get_bot_options($bot_id);
5120 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
5121
5122 $similarity_threshold = isset($current_options['similarity_threshold'])
5123 ? ((int) $current_options['similarity_threshold']) / 100
5124 : 0.35;
5125
5126 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
5127
5128 // Prepare the query request for Pinecone
5129 $api_endpoint = "https://{$host}/query";
5130
5131 $request_body = array(
5132 'vector' => $user_embedding,
5133 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
5134 'includeMetadata' => true,
5135 'includeValues' => true
5136 );
5137
5138 // Add namespace if specified for this bot
5139 if (!empty($namespace)) {
5140 $request_body['namespace'] = $namespace;
5141 }
5142
5143 error_log("MXCHAT DEBUG: About to call Pinecone API");
5144 error_log(" - Endpoint: " . $api_endpoint);
5145 error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
5146
5147 $response = wp_remote_post($api_endpoint, array(
5148 'headers' => array(
5149 'Api-Key' => $api_key,
5150 'accept' => 'application/json',
5151 'content-type' => 'application/json'
5152 ),
5153 'body' => wp_json_encode($request_body),
5154 'timeout' => 30
5155 ));
5156
5157 if (is_wp_error($response)) {
5158 error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
5159 // Store empty array for valid URLs
5160 $this->current_valid_urls = [];
5161 return '';
5162 }
5163
5164 $response_code = wp_remote_retrieve_response_code($response);
5165 error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
5166
5167 if ($response_code !== 200) {
5168 $response_body = wp_remote_retrieve_body($response);
5169 error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
5170 // Store empty array for valid URLs
5171 $this->current_valid_urls = [];
5172 return '';
5173 }
5174
5175 // ADD DETAILED DEBUG SECTION HERE
5176 $response_body = wp_remote_retrieve_body($response);
5177 error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
5178
5179 $results = json_decode($response_body, true);
5180
5181 if (json_last_error() !== JSON_ERROR_NONE) {
5182 error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
5183 error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
5184 // Store empty array for valid URLs
5185 $this->current_valid_urls = [];
5186 return '';
5187 }
5188
5189 error_log("MXCHAT DEBUG: Pinecone response structure:");
5190 error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
5191 error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
5192
5193 if (empty($results['matches'])) {
5194 error_log("MXCHAT DEBUG: No matches found in Pinecone response");
5195 error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
5196 // Store empty array for valid URLs
5197 $this->current_valid_urls = [];
5198 return '';
5199 }
5200
5201 error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
5202
5203 // Log first match details for debugging
5204 if (!empty($results['matches'][0])) {
5205 $first_match = $results['matches'][0];
5206 error_log("MXCHAT DEBUG: First match details:");
5207 error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
5208 error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
5209 if (isset($first_match['metadata'])) {
5210 error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
5211 }
5212 }
5213
5214 // Initialize the final content
5215 $content = '';
5216 $matches_used = 0;
5217 $matches_used_for_context = [];
5218 $total_chunks_used = 0;
5219 $max_total_chunks = 30; // Hard cap on total chunks to prevent excessive token usage
5220
5221 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5222 // Use fresh options to ensure we get the latest setting value
5223 $fresh_options = get_option('mxchat_options', []);
5224 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5225
5226 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
5227 $url_groups = array();
5228
5229 foreach ($results['matches'] as $index => $match) {
5230 // Skip if similarity is below threshold
5231 if ($match['score'] < $similarity_threshold) {
5232 continue;
5233 }
5234
5235 $metadata = $match['metadata'] ?? array();
5236 $source_url = $metadata['source_url'] ?? '';
5237 $match_id = $match['id'] ?? '';
5238
5239 // LAZY ROLE CHECK: Only check role for content we're actually considering
5240 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
5241 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5242
5243 // Skip if user doesn't have access
5244 if (!$has_access) {
5245 continue;
5246 }
5247
5248 // Use a unique key for manual entries without a source URL
5249 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
5250
5251 // Group by source URL (or unique key for manual entries)
5252 if (!isset($url_groups[$group_key])) {
5253 $url_groups[$group_key] = array(
5254 'source_url' => $source_url,
5255 'best_score' => 0,
5256 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
5257 'chunks' => array(),
5258 'single_text' => ''
5259 );
5260 }
5261
5262 // Track best score for this group
5263 if ($match['score'] > $url_groups[$group_key]['best_score']) {
5264 $url_groups[$group_key]['best_score'] = $match['score'];
5265 }
5266
5267 // Store chunk info or single text
5268 if ($url_groups[$group_key]['is_chunked']) {
5269 $url_groups[$group_key]['chunks'][] = array(
5270 'id' => $match_id,
5271 'score' => $match['score'],
5272 'chunk_index' => $metadata['chunk_index'] ?? 0,
5273 'text' => $metadata['text'] ?? ''
5274 );
5275 } else {
5276 // Non-chunked content - just store the text
5277 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
5278 $url_groups[$group_key]['single_id'] = $match_id;
5279 }
5280 }
5281
5282 // Sort URL groups by best score (highest first)
5283 uasort($url_groups, function($a, $b) {
5284 return $b['best_score'] <=> $a['best_score'];
5285 });
5286
5287 // Get RAG sources limit from options (default 6, min 3, max 10)
5288 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 6;
5289 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
5290 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
5291
5292 // Take top N unique URLs based on user setting
5293 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
5294
5295 // Track which match IDs are actually used for context
5296 foreach ($top_urls as $group) {
5297 if ($group['is_chunked']) {
5298 foreach ($group['chunks'] as $chunk) {
5299 $matches_used_for_context[] = $chunk['id'];
5300 }
5301 } elseif (!empty($group['single_id'])) {
5302 $matches_used_for_context[] = $group['single_id'];
5303 }
5304 }
5305
5306 // Build content from top sources
5307 foreach ($top_urls as $group_key => $group) {
5308 $source_url = $group['source_url']; // Use actual source_url, not the group key
5309
5310 // Stop if we've hit the total chunk limit
5311 if ($total_chunks_used >= $max_total_chunks) {
5312 break;
5313 }
5314
5315 $full_text = '';
5316 $chunks_in_this_source = 1; // Default for non-chunked content
5317
5318 if ($group['is_chunked']) {
5319 // Calculate how many chunks we can still use
5320 $chunks_remaining = $max_total_chunks - $total_chunks_used;
5321
5322 // Fetch chunks for this URL with limit
5323 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
5324
5325 // If fetching all chunks fails, fall back to matched chunks
5326 if (empty($full_text)) {
5327 // Sort matched chunks by index and concatenate
5328 usort($group['chunks'], function($a, $b) {
5329 return $a['chunk_index'] <=> $b['chunk_index'];
5330 });
5331
5332 $chunk_texts = array();
5333 $chunks_in_this_source = 0;
5334 foreach ($group['chunks'] as $chunk) {
5335 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5336 break;
5337 }
5338 $chunk_texts[] = $chunk['text'];
5339 $chunks_in_this_source++;
5340 }
5341 $full_text = implode("\n\n", $chunk_texts);
5342 }
5343 } else {
5344 $full_text = $group['single_text'];
5345 $chunks_in_this_source = 1;
5346 }
5347
5348 if (!empty($full_text)) {
5349 // Strip URLs from content if citation links are disabled
5350 if (!$citation_links_enabled) {
5351 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5352 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5353 }
5354
5355 // Use numbered reference for URL-based entries, plain info label for manual entries
5356 if (!empty($source_url) && $source_url !== '#') {
5357 $matches_used++;
5358 $content .= "## Reference " . $matches_used . " ##\n";
5359 $content .= $full_text . "\n\n";
5360
5361 // Only include citation URLs if citation links are enabled
5362 if ($citation_links_enabled) {
5363 $valid_urls[] = $source_url;
5364 $content .= "URL: " . $source_url . "\n\n";
5365 }
5366 } else {
5367 // Manual entry — no reference number, no citation
5368 $content .= "## Information ##\n";
5369 $content .= $full_text . "\n\n";
5370 }
5371
5372 // Extract any URLs from the text content itself (only if citation links enabled)
5373 if ($citation_links_enabled) {
5374 preg_match_all(
5375 '#\bhttps?://[^\s<>"\']+#i',
5376 $full_text,
5377 $content_urls
5378 );
5379 if (!empty($content_urls[0])) {
5380 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5381 }
5382 }
5383
5384 $total_chunks_used += $chunks_in_this_source;
5385 }
5386 }
5387
5388 // Process ALL matches for testing data (top 10) - with role checking for testing display
5389 $all_matches = [];
5390 foreach ($results['matches'] as $index => $match) {
5391 if ($index >= 10) break; // Limit to top 10 for testing
5392
5393 $match_id = $match['id'] ?? '';
5394
5395 // Check role access for testing display (use cache if available)
5396 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
5397 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5398
5399 $source_display = '';
5400 if (!empty($match['metadata']['source_url'])) {
5401 $source_display = $match['metadata']['source_url'];
5402 } else {
5403 $content_preview = strip_tags($match['metadata']['text'] ?? '');
5404 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
5405 $source_display = substr(trim($content_preview), 0, 50) . '...';
5406 }
5407
5408 $match_id_for_display = $match['id'] ?? $index;
5409
5410 // Check for chunk metadata in Pinecone
5411 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
5412 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
5413 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
5414
5415 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
5416 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
5417 $is_chunk = true;
5418 }
5419
5420 $all_matches[] = [
5421 'document_id' => $match_id_for_display,
5422 'similarity' => $match['score'],
5423 'similarity_percentage' => round($match['score'] * 100, 2),
5424 'above_threshold' => $match['score'] >= $similarity_threshold,
5425 'source_display' => $source_display,
5426 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
5427 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
5428 'role_restriction' => $role_restriction,
5429 'has_access' => $has_access,
5430 'filtered_out' => !$has_access,
5431 'is_chunk' => $is_chunk,
5432 'chunk_index' => $chunk_index,
5433 'total_chunks' => $total_chunks
5434 ];
5435 }
5436
5437 // Store for testing panel
5438 $this->last_similarity_analysis['top_matches'] = $all_matches;
5439 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
5440
5441 // NEW: Store unique valid URLs for validation
5442 $this->current_valid_urls = array_unique($valid_urls);
5443
5444 // Add response guidelines
5445 if ($matches_used === 0) {
5446 $content = "No reference information was found for this query.\n\n";
5447 } else {
5448 // Build response guidelines based on citation links setting
5449 $content .= "\n## Response Guidelines ##\n" .
5450 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5451 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5452 "If you don't have specific information or are uncertain about any details, it's always " .
5453 "better to honestly say you don't know rather than making up or guessing at answers. " .
5454 "When information is incomplete, let them know you are unsure.\n\n";
5455
5456 // Only add hyperlink instructions if citation links are enabled
5457 if ($citation_links_enabled) {
5458 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5459 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5460 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5461 } else {
5462 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5463 "Simply provide helpful answers based on the reference information without citing sources.";
5464 }
5465 }
5466
5467 return trim($content);
5468 }
5469
5470 /**
5471 * Get role restriction for a single vector (with caching)
5472 */
5473 private function get_single_vector_role($vector_id, $metadata = array()) {
5474 global $wpdb;
5475
5476 if (empty($vector_id)) {
5477 return 'public';
5478 }
5479
5480 // Check cache first
5481 $cache_key = 'mxchat_vector_role_' . $vector_id;
5482 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
5483
5484 if ($cached_role !== false) {
5485 return $cached_role;
5486 }
5487
5488 $role_restriction = 'public';
5489
5490 // First try Pinecone metadata
5491 if (!empty($metadata['role_restriction'])) {
5492 $role_restriction = $metadata['role_restriction'];
5493 } else {
5494 // Check WordPress table for user-modified roles
5495 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5496 $stored_role = $wpdb->get_var($wpdb->prepare(
5497 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
5498 $vector_id
5499 ));
5500
5501 if ($stored_role) {
5502 $role_restriction = $stored_role;
5503 }
5504 }
5505
5506 // Cache individual role for 1 hour
5507 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
5508
5509 return $role_restriction;
5510 }
5511
5512 /**
5513 * Fetch and reassemble all chunks for a URL from Pinecone
5514 *
5515 * @param string $source_url The source URL to fetch chunks for
5516 * @param array $bot_config Bot-specific Pinecone configuration
5517 * @return string Reassembled content from all chunks
5518 */
5519 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
5520 $api_key = $bot_config['api_key'] ?? '';
5521 $host = $bot_config['host'] ?? '';
5522 $namespace = $bot_config['namespace'] ?? '';
5523
5524 if (empty($host) || empty($api_key)) {
5525 $chunk_count = 0;
5526 return '';
5527 }
5528
5529 $base_hash = md5($source_url);
5530
5531 // Use Pinecone list API to find all chunk vectors with this prefix
5532 $list_url = "https://{$host}/vectors/list";
5533
5534 // Limit to max_chunks if specified, otherwise fetch up to 100
5535 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
5536
5537 $list_body = array(
5538 'prefix' => $base_hash . '_chunk_',
5539 'limit' => $fetch_limit
5540 );
5541
5542 if (!empty($namespace)) {
5543 $list_body['namespace'] = $namespace;
5544 }
5545
5546 $list_response = wp_remote_post($list_url, array(
5547 'headers' => array(
5548 'Api-Key' => $api_key,
5549 'accept' => 'application/json',
5550 'content-type' => 'application/json'
5551 ),
5552 'body' => wp_json_encode($list_body),
5553 'timeout' => 30
5554 ));
5555
5556 if (is_wp_error($list_response)) {
5557 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
5558 return '';
5559 }
5560
5561 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
5562
5563 if (empty($list_data['vectors'])) {
5564 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
5565 return '';
5566 }
5567
5568 // Extract vector IDs
5569 $vector_ids = array();
5570 foreach ($list_data['vectors'] as $vector) {
5571 if (isset($vector['id'])) {
5572 $vector_ids[] = $vector['id'];
5573 }
5574 }
5575
5576 if (empty($vector_ids)) {
5577 return '';
5578 }
5579
5580 // Fetch all chunk content
5581 $fetch_url = "https://{$host}/vectors/fetch";
5582
5583 $fetch_body = array(
5584 'ids' => $vector_ids
5585 );
5586
5587 if (!empty($namespace)) {
5588 $fetch_body['namespace'] = $namespace;
5589 }
5590
5591 $fetch_response = wp_remote_post($fetch_url, array(
5592 'headers' => array(
5593 'Api-Key' => $api_key,
5594 'accept' => 'application/json',
5595 'content-type' => 'application/json'
5596 ),
5597 'body' => wp_json_encode($fetch_body),
5598 'timeout' => 30
5599 ));
5600
5601 if (is_wp_error($fetch_response)) {
5602 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
5603 return '';
5604 }
5605
5606 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
5607
5608 if (empty($fetch_data['vectors'])) {
5609 return '';
5610 }
5611
5612 // Sort chunks by index and reassemble
5613 $chunks = array();
5614 foreach ($fetch_data['vectors'] as $id => $vector) {
5615 $metadata = $vector['metadata'] ?? array();
5616 $chunk_index = $metadata['chunk_index'] ?? 0;
5617 $text = $metadata['text'] ?? '';
5618
5619 // Store chunk with its index
5620 $chunks[$chunk_index] = $text;
5621 }
5622
5623 // Sort by chunk index
5624 ksort($chunks);
5625
5626 // Apply chunk limit if specified
5627 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5628 $chunks = array_slice($chunks, 0, $max_chunks, true);
5629 }
5630
5631 // Store actual chunk count
5632 $chunk_count = count($chunks);
5633
5634 // Reassemble content
5635 return implode("\n\n", $chunks);
5636 }
5637
5638 /**
5639 * Search for relevant content using OpenAI Vector Store (File Search)
5640 *
5641 * @param string $user_query The user's query text
5642 * @param string $bot_id The bot ID
5643 * @param array $vectorstore_config Vector Store configuration
5644 * @return string Formatted context string with references
5645 */
5646 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
5647 error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
5648 error_log(" - bot_id: " . $bot_id);
5649 error_log(" - user_query length: " . strlen($user_query));
5650
5651 // Get OpenAI API key
5652 $mxchat_options = get_option('mxchat_options', array());
5653 $api_key = $mxchat_options['api_key'] ?? '';
5654
5655 if (empty($api_key)) {
5656 error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
5657 $this->current_valid_urls = [];
5658 return '';
5659 }
5660
5661 // Get Vector Store configuration
5662 if (empty($vectorstore_config)) {
5663 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
5664 }
5665
5666 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
5667 $max_results = $vectorstore_config['max_results'] ?? 5;
5668
5669 if (empty($vectorstore_ids_string)) {
5670 error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
5671 $this->current_valid_urls = [];
5672 return '';
5673 }
5674
5675 // Parse Vector Store IDs
5676 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
5677 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
5678
5679 error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5680 error_log("MXCHAT DEBUG: Max results: " . $max_results);
5681
5682 // Initialize similarity analysis storage
5683 $this->last_similarity_analysis = [
5684 'knowledge_base_type' => 'OpenAI Vector Store',
5685 'bot_id' => $bot_id,
5686 'vectorstore_ids' => $vectorstore_ids,
5687 'top_matches' => [],
5688 'threshold_used' => 0,
5689 'total_checked' => 0
5690 ];
5691
5692 $valid_urls = [];
5693
5694 // Get the selected model
5695 $bot_options = $this->get_bot_options($bot_id);
5696 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
5697 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
5698
5699 // Verify it's an OpenAI model
5700 if (!$this->is_openai_chat_model($selected_model)) {
5701 error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
5702 $this->current_valid_urls = [];
5703 return '';
5704 }
5705
5706 // Use OpenAI Responses API with file_search tool
5707 $request_body = array(
5708 'model' => $selected_model,
5709 'input' => $user_query,
5710 'tools' => array(
5711 array(
5712 'type' => 'file_search',
5713 'vector_store_ids' => $vectorstore_ids,
5714 'max_num_results' => intval($max_results)
5715 )
5716 ),
5717 'include' => array('output[*].file_search_call.search_results')
5718 );
5719
5720 error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
5721 error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
5722 error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
5723 error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5724 error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
5725 error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
5726
5727 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
5728 'headers' => array(
5729 'Authorization' => 'Bearer ' . $api_key,
5730 'Content-Type' => 'application/json'
5731 ),
5732 'body' => wp_json_encode($request_body),
5733 'timeout' => 60
5734 ));
5735
5736 if (is_wp_error($response)) {
5737 error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
5738 $this->current_valid_urls = [];
5739 return '';
5740 }
5741
5742 $response_code = wp_remote_retrieve_response_code($response);
5743 error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
5744
5745 $response_body = wp_remote_retrieve_body($response);
5746 error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
5747
5748 if ($response_code !== 200) {
5749 error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
5750 $this->current_valid_urls = [];
5751 return '';
5752 }
5753 $result = json_decode($response_body, true);
5754
5755 if (json_last_error() !== JSON_ERROR_NONE) {
5756 error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
5757 $this->current_valid_urls = [];
5758 return '';
5759 }
5760
5761 // Debug: Log the structure of the result
5762 error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
5763 if (isset($result['output'])) {
5764 error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
5765 foreach ($result['output'] as $idx => $out) {
5766 error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
5767 error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
5768 }
5769 } else {
5770 error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
5771 }
5772
5773 // Extract file search results from the response
5774 $content = '';
5775 $matches_used = 0;
5776 $all_matches = [];
5777
5778 // The Responses API returns output array with tool results
5779 if (isset($result['output']) && is_array($result['output'])) {
5780 foreach ($result['output'] as $output_item) {
5781 // Look for file_search_call results
5782 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
5783 error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
5784 error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
5785
5786 // Check for search_results in the output item directly
5787 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
5788 error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
5789
5790 if (empty($search_results)) {
5791 error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
5792 error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
5793 }
5794
5795 foreach ($search_results as $index => $search_result) {
5796 $filename = $search_result['filename'] ?? '';
5797 $score = $search_result['score'] ?? 0;
5798 $text_content = '';
5799
5800 // Extract text content from the result
5801 // The text can be directly on the result OR nested under content array
5802 if (isset($search_result['text']) && !empty($search_result['text'])) {
5803 // Direct text field (OpenAI's actual format)
5804 $text_content = $search_result['text'];
5805 error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
5806 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
5807 // Nested content array format
5808 foreach ($search_result['content'] as $content_item) {
5809 if (isset($content_item['text'])) {
5810 $text_content .= $content_item['text'] . "\n";
5811 }
5812 }
5813 error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
5814 } else {
5815 error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
5816 }
5817
5818 if (!empty($text_content)) {
5819 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5820 $content .= trim($text_content) . "\n\n";
5821
5822 if (!empty($filename)) {
5823 $content .= "Source: " . $filename . "\n\n";
5824 }
5825
5826 // Extract URLs from content
5827 preg_match_all(
5828 '#\bhttps?://[^\s<>"\']+#i',
5829 $text_content,
5830 $content_urls
5831 );
5832 if (!empty($content_urls[0])) {
5833 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5834 }
5835
5836 $matches_used++;
5837 }
5838
5839 // Store for similarity analysis
5840 $all_matches[] = [
5841 'document_id' => $filename ?: ('result_' . $index),
5842 'similarity' => $score,
5843 'similarity_percentage' => round($score * 100, 2),
5844 'above_threshold' => true,
5845 'source_display' => $filename,
5846 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5847 'used_for_context' => true,
5848 'role_restriction' => 'public',
5849 'has_access' => true,
5850 'filtered_out' => false
5851 ];
5852 }
5853 }
5854
5855 // Also check for message content with annotations (citations)
5856 if (isset($output_item['type']) && $output_item['type'] === 'message') {
5857 if (isset($output_item['content']) && is_array($output_item['content'])) {
5858 foreach ($output_item['content'] as $content_block) {
5859 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
5860 foreach ($content_block['annotations'] as $annotation) {
5861 if (isset($annotation['filename'])) {
5862 $filename = $annotation['filename'];
5863 $score = $annotation['score'] ?? 0;
5864 $text_content = '';
5865
5866 if (isset($annotation['content']) && is_array($annotation['content'])) {
5867 foreach ($annotation['content'] as $ann_content) {
5868 if (isset($ann_content['text'])) {
5869 $text_content .= $ann_content['text'] . "\n";
5870 }
5871 }
5872 }
5873
5874 if (!empty($text_content) && $matches_used < $max_results) {
5875 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5876 $content .= trim($text_content) . "\n\n";
5877 $content .= "Source: " . $filename . "\n\n";
5878
5879 preg_match_all(
5880 '#\bhttps?://[^\s<>"\']+#i',
5881 $text_content,
5882 $content_urls
5883 );
5884 if (!empty($content_urls[0])) {
5885 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5886 }
5887
5888 $matches_used++;
5889
5890 $all_matches[] = [
5891 'document_id' => $filename,
5892 'similarity' => $score,
5893 'similarity_percentage' => round($score * 100, 2),
5894 'above_threshold' => true,
5895 'source_display' => $filename,
5896 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5897 'used_for_context' => true,
5898 'role_restriction' => 'public',
5899 'has_access' => true,
5900 'filtered_out' => false
5901 ];
5902 }
5903 }
5904 }
5905 }
5906 }
5907 }
5908 }
5909 }
5910 }
5911
5912 // Store for testing panel
5913 $this->last_similarity_analysis['top_matches'] = $all_matches;
5914 $this->last_similarity_analysis['total_checked'] = count($all_matches);
5915
5916 // Store unique valid URLs for validation
5917 $this->current_valid_urls = array_unique($valid_urls);
5918
5919 error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
5920 error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
5921 error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
5922 error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
5923 if ($matches_used > 0) {
5924 error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
5925 }
5926
5927 // Check if citation links are enabled
5928 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
5929
5930 // Add response guidelines
5931 if ($matches_used === 0) {
5932 error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
5933 $content = "No reference information was found for this query.\n\n";
5934 } else {
5935 // Build response guidelines based on citation links setting
5936 $content .= "\n## Response Guidelines ##\n" .
5937 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5938 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5939 "If you don't have specific information or are uncertain about any details, it's always " .
5940 "better to honestly say you don't know rather than making up or guessing at answers. " .
5941 "When information is incomplete, let them know you are unsure.\n\n";
5942
5943 // Only add hyperlink instructions if citation links are enabled
5944 if ($citation_links_enabled) {
5945 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5946 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
5947 } else {
5948 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5949 "Simply provide helpful answers based on the reference information without citing sources.";
5950 }
5951 }
5952
5953 error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
5954
5955 return trim($content);
5956 }
5957
5958 /**
5959 * Check if the given model is an OpenAI chat model
5960 *
5961 * @param string $model The model ID
5962 * @return bool True if it's an OpenAI model
5963 */
5964 private function is_openai_chat_model($model) {
5965 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
5966 foreach ($openai_prefixes as $prefix) {
5967 if (strpos($model, $prefix) === 0) {
5968 return true;
5969 }
5970 }
5971 return false;
5972 }
5973
5974 /**
5975 * Get bot-specific Vector Store configuration
5976 *
5977 * @param string $bot_id The bot ID
5978 * @return array Configuration array
5979 */
5980 private function get_bot_vectorstore_config($bot_id = 'default') {
5981 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
5982
5983 // Default global settings
5984 $default_config = array(
5985 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
5986 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
5987 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
5988 );
5989
5990 // Allow multi-bot plugin to override with bot-specific settings
5991 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
5992
5993 // Preserve max_results from global settings if not set in bot config
5994 if (!isset($bot_config['max_results'])) {
5995 $bot_config['max_results'] = $default_config['max_results'];
5996 }
5997
5998 return $bot_config;
5999 }
6000
6001 private function mxchat_find_relevant_products($user_embedding) {
6002 //error_log('MXChat Vector Search: Starting product search...');
6003
6004 // Retrieve the add-on settings from the database
6005 $addon_options = get_option('mxchat_pinecone_addon_options', array());
6006
6007 // Determine whether Pinecone is enabled
6008 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
6009
6010 //error_log('Pinecone enabled flag: ' . $use_pinecone);
6011
6012 if ($use_pinecone === 1) {
6013 //error_log('MXChat Vector Search: Using Pinecone database for products');
6014 return $this->find_relevant_products_pinecone($user_embedding);
6015 } else {
6016 //error_log('MXChat Vector Search: Using WordPress database for products');
6017 return $this->find_relevant_products_wordpress($user_embedding);
6018 }
6019 }
6020 private function find_relevant_products_wordpress($user_embedding) {
6021 global $wpdb;
6022 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6023 $cache_key = 'mxchat_system_prompt_embeddings';
6024 $batch_size = 500;
6025
6026 // Original WordPress database search logic
6027 // [Previous implementation remains the same]
6028 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
6029 if ($embeddings === false) {
6030 $embeddings = [];
6031 $offset = 0;
6032
6033 do {
6034 $query = $wpdb->prepare(
6035 "SELECT id, embedding_vector
6036 FROM {$system_prompt_table}
6037 LIMIT %d OFFSET %d",
6038 $batch_size,
6039 $offset
6040 );
6041
6042 $batch = $wpdb->get_results($query);
6043 if (empty($batch)) {
6044 break;
6045 }
6046
6047 $embeddings = array_merge($embeddings, $batch);
6048 $offset += $batch_size;
6049
6050 unset($batch);
6051
6052 } while (true);
6053
6054 if (empty($embeddings)) {
6055 return '';
6056 }
6057 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
6058 }
6059
6060 $relevant_results = [];
6061 foreach ($embeddings as $embedding) {
6062 $database_embedding = $embedding->embedding_vector
6063 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
6064 : null;
6065 if (is_array($database_embedding) && is_array($user_embedding)) {
6066 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6067 $relevant_results[] = [
6068 'id' => $embedding->id,
6069 'similarity' => $similarity
6070 ];
6071 }
6072 unset($database_embedding);
6073 }
6074
6075 // Use fixed threshold for products
6076 $similarity_threshold = 0.85;
6077
6078 $relevant_results = array_filter($relevant_results, function ($result) use ($similarity_threshold) {
6079 return $result['similarity'] >= $similarity_threshold;
6080 });
6081 usort($relevant_results, function ($a, $b) {
6082 return $b['similarity'] <=> $a['similarity'];
6083 });
6084
6085 $top_results = array_slice($relevant_results, 0, 3);
6086 $content = '';
6087
6088 foreach ($top_results as $result) {
6089 $chunk_content = $this->fetch_content_with_product_links($result['id']);
6090 $content .= $chunk_content . "\n\n";
6091 }
6092
6093 return trim($content);
6094 }
6095
6096
6097 private function find_relevant_products_pinecone($user_embedding) {
6098 //error_log('Starting Pinecone product search...');
6099
6100 $options = get_option('mxchat_pinecone_addon_options', array());
6101 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
6102 $host = $options['mxchat_pinecone_host'] ?? '';
6103
6104 if (empty($host) || empty($api_key)) {
6105 //error_log('Pinecone credentials not properly configured for product search');
6106 return '';
6107 }
6108
6109 $similarity_threshold = 0.85;
6110 $api_endpoint = "https://{$host}/query";
6111
6112 $request_body = array(
6113 'vector' => $user_embedding,
6114 'topK' => 5,
6115 'includeMetadata' => true,
6116 'includeValues' => true,
6117 'filter' => array(
6118 'type' => 'product'
6119 )
6120 );
6121
6122 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
6123
6124 $response = wp_remote_post($api_endpoint, array(
6125 'headers' => array(
6126 'Api-Key' => $api_key,
6127 'accept' => 'application/json',
6128 'content-type' => 'application/json'
6129 ),
6130 'body' => wp_json_encode($request_body),
6131 'timeout' => 30
6132 ));
6133
6134 if (is_wp_error($response)) {
6135 //error_log('Pinecone product query error: ' . $response->get_error_message());
6136 return '';
6137 }
6138
6139 $response_code = wp_remote_retrieve_response_code($response);
6140 //error_log('Pinecone response code: ' . $response_code);
6141
6142 if ($response_code !== 200) {
6143 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
6144 return '';
6145 }
6146
6147 $results = json_decode(wp_remote_retrieve_body($response), true);
6148 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
6149
6150 if (empty($results['matches'])) {
6151 //error_log('No matches found in Pinecone response');
6152 return '';
6153 }
6154
6155 $content = '';
6156 foreach ($results['matches'] as $match) {
6157 if ($match['score'] < $similarity_threshold) {
6158 //error_log("Match below threshold: " . $match['score']);
6159 continue;
6160 }
6161
6162 if (!empty($match['metadata']['text'])) {
6163 $content .= $match['metadata']['text'];
6164 if (!empty($match['metadata']['source_url'])) {
6165 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
6166 }
6167 $content .= "\n\n";
6168 }
6169 }
6170
6171 return trim($content);
6172 }
6173
6174
6175 private function fetch_content_with_product_links($most_relevant_id) {
6176 global $wpdb;
6177 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6178
6179 // Fetch the article content and associated product URL
6180 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
6181 $result = $wpdb->get_row($query);
6182
6183 if ($result) {
6184 // Append the product link to the content if available
6185 $content = $result->article_content;
6186 if (!empty($result->source_url)) {
6187 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
6188 }
6189 return $content;
6190 }
6191
6192 return null;
6193 }
6194
6195 /**
6196 * Get system instructions for a specific bot or default
6197 * Checks for multi-bot add-on and uses bot-specific instructions if available
6198 * Automatically strips URLs if citation links are disabled
6199 * Replaces {visitor_name} placeholder with actual visitor name if available
6200 *
6201 * @param string $bot_id The bot ID to get instructions for
6202 * @param string $session_id Optional session ID to lookup visitor name
6203 */
6204 private function get_system_instructions($bot_id = 'default', $session_id = '') {
6205 $instructions = '';
6206
6207 // Check if multi-bot add-on is active
6208 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
6209 // Get bot-specific options from multi-bot add-on
6210 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
6211
6212 // If bot has custom system instructions, use those
6213 if (!empty($bot_options['system_prompt_instructions'])) {
6214 $instructions = $bot_options['system_prompt_instructions'];
6215 }
6216 }
6217
6218 // Fall back to default system instructions
6219 if (empty($instructions)) {
6220 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
6221 }
6222
6223 // Check if citation links are disabled - if so, strip URLs from instructions
6224 $fresh_options = get_option('mxchat_options', []);
6225 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6226
6227 if (!$citation_links_enabled && !empty($instructions)) {
6228 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
6229 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
6230 }
6231
6232 // Replace {visitor_name} placeholder with actual visitor name if available
6233 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
6234 $name_option_key = "mxchat_name_{$session_id}";
6235 $visitor_name = get_option($name_option_key, '');
6236
6237 if (!empty($visitor_name)) {
6238 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
6239 } else {
6240 // Remove placeholder if no name is available
6241 $instructions = str_ireplace('{visitor_name}', '', $instructions);
6242 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
6243 }
6244 }
6245
6246 // Allow developers to filter system instructions and process shortcodes
6247 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
6248 $instructions = do_shortcode($instructions);
6249
6250 return $instructions;
6251 }
6252 /**
6253 * Get the current bot ID from session or request context
6254 */
6255 private function get_current_bot_id($session_id = '') {
6256 // First, check if bot_id is passed in the current request
6257 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
6258 return sanitize_key($_POST['bot_id']);
6259 }
6260
6261 // If not in POST, try to get it from session data
6262 if (!empty($session_id)) {
6263 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
6264 if (!empty($bot_id)) {
6265 return $bot_id;
6266 }
6267 }
6268
6269 // Fall back to default
6270 return 'default';
6271 }
6272 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') {
6273 try {
6274 if (!$relevant_content) {
6275 $error_response = [
6276 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
6277 'error_code' => 'no_relevant_content'
6278 ];
6279
6280 if ($testing_data !== null) {
6281 $error_response['testing_data'] = $testing_data;
6282 }
6283
6284 return $error_response;
6285 }
6286
6287 if (!is_array($conversation_history)) {
6288 $conversation_history = array();
6289 }
6290
6291 // Check if this is an OpenRouter model
6292 if ($selected_model === 'openrouter') {
6293 // Get the actual OpenRouter model from options
6294 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
6295
6296 if (empty($openrouter_selected_model)) {
6297 $error_response = [
6298 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
6299 'error_code' => 'no_openrouter_model_selected'
6300 ];
6301 if ($testing_data !== null) {
6302 $error_response['testing_data'] = $testing_data;
6303 }
6304 return $error_response;
6305 }
6306
6307 if (empty($openrouter_api_key)) {
6308 $error_response = [
6309 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
6310 'error_code' => 'missing_openrouter_api_key'
6311 ];
6312 if ($testing_data !== null) {
6313 $error_response['testing_data'] = $testing_data;
6314 }
6315 return $error_response;
6316 }
6317
6318 if ($streaming) {
6319 return $this->mxchat_generate_response_openrouter_stream(
6320 $openrouter_selected_model,
6321 $openrouter_api_key,
6322 $conversation_history,
6323 $relevant_content,
6324 $session_id,
6325 $testing_data
6326 );
6327 } else {
6328 $response = $this->mxchat_generate_response_openrouter(
6329 $openrouter_selected_model,
6330 $openrouter_api_key,
6331 $conversation_history,
6332 $relevant_content
6333 );
6334 }
6335
6336 if (is_array($response) && isset($response['error'])) {
6337 if ($testing_data !== null) {
6338 $response['testing_data'] = $testing_data;
6339 }
6340 return $response;
6341 }
6342
6343 return $response;
6344 }
6345
6346 // Extract model prefix to determine the provider
6347 $model_parts = explode('-', $selected_model);
6348 $provider = strtolower($model_parts[0]);
6349
6350 // Handle model selection based on provider prefix
6351 switch ($provider) {
6352 case 'gemini':
6353 if (empty($gemini_api_key)) {
6354 $error_response = [
6355 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
6356 'error_code' => 'missing_gemini_api_key'
6357 ];
6358 if ($testing_data !== null) {
6359 $error_response['testing_data'] = $testing_data;
6360 }
6361 return $error_response;
6362 }
6363 $response = $this->mxchat_generate_response_gemini(
6364 $selected_model,
6365 $gemini_api_key,
6366 $conversation_history,
6367 $relevant_content
6368 );
6369 break;
6370
6371 case 'claude':
6372 if (empty($claude_api_key)) {
6373 $error_response = [
6374 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
6375 'error_code' => 'missing_claude_api_key'
6376 ];
6377 if ($testing_data !== null) {
6378 $error_response['testing_data'] = $testing_data;
6379 }
6380 return $error_response;
6381 }
6382 if ($streaming) {
6383 return $this->mxchat_generate_response_claude_stream(
6384 $selected_model,
6385 $claude_api_key,
6386 $conversation_history,
6387 $relevant_content,
6388 $session_id,
6389 $testing_data
6390 );
6391 } else {
6392 $response = $this->mxchat_generate_response_claude(
6393 $selected_model,
6394 $claude_api_key,
6395 $conversation_history,
6396 $relevant_content
6397 );
6398 }
6399 break;
6400
6401 case 'grok':
6402 if (empty($xai_api_key)) {
6403 $error_response = [
6404 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
6405 'error_code' => 'missing_xai_api_key'
6406 ];
6407 if ($testing_data !== null) {
6408 $error_response['testing_data'] = $testing_data;
6409 }
6410 return $error_response;
6411 }
6412 if ($streaming) {
6413 return $this->mxchat_generate_response_xai_stream(
6414 $selected_model,
6415 $xai_api_key,
6416 $conversation_history,
6417 $relevant_content,
6418 $session_id,
6419 $testing_data
6420 );
6421 } else {
6422 $response = $this->mxchat_generate_response_xai(
6423 $selected_model,
6424 $xai_api_key,
6425 $conversation_history,
6426 $relevant_content
6427 );
6428 }
6429 break;
6430
6431 case 'deepseek':
6432 if (empty($deepseek_api_key)) {
6433 $error_response = [
6434 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
6435 'error_code' => 'missing_deepseek_api_key'
6436 ];
6437 if ($testing_data !== null) {
6438 $error_response['testing_data'] = $testing_data;
6439 }
6440 return $error_response;
6441 }
6442 if ($streaming) {
6443 return $this->mxchat_generate_response_deepseek_stream(
6444 $selected_model,
6445 $deepseek_api_key,
6446 $conversation_history,
6447 $relevant_content,
6448 $session_id,
6449 $testing_data
6450 );
6451 } else {
6452 $response = $this->mxchat_generate_response_deepseek(
6453 $selected_model,
6454 $deepseek_api_key,
6455 $conversation_history,
6456 $relevant_content
6457 );
6458 }
6459 break;
6460
6461 case 'gpt':
6462 case 'o1':
6463 if (empty($api_key)) {
6464 $error_response = [
6465 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6466 'error_code' => 'missing_openai_api_key'
6467 ];
6468 if ($testing_data !== null) {
6469 $error_response['testing_data'] = $testing_data;
6470 }
6471 return $error_response;
6472 }
6473
6474 // Check if web search is enabled for this OpenAI model
6475 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6476 // Models that don't support web search
6477 $unsupported_web_search_models = array('gpt-4.1-nano');
6478 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6479
6480 if ($web_search_enabled && $model_supports_web_search) {
6481 // Use Responses API with web search
6482 return $this->mxchat_generate_response_openai_web_search(
6483 $selected_model,
6484 $api_key,
6485 $conversation_history,
6486 $relevant_content,
6487 $session_id,
6488 $testing_data,
6489 $streaming
6490 );
6491 } elseif ($streaming) {
6492 return $this->mxchat_generate_response_openai_stream(
6493 $selected_model,
6494 $api_key,
6495 $conversation_history,
6496 $relevant_content,
6497 $session_id,
6498 $testing_data
6499 );
6500 } else {
6501 $response = $this->mxchat_generate_response_openai(
6502 $selected_model,
6503 $api_key,
6504 $conversation_history,
6505 $relevant_content
6506 );
6507 }
6508 break;
6509
6510 default:
6511 if (empty($api_key)) {
6512 $error_response = [
6513 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6514 'error_code' => 'missing_openai_api_key'
6515 ];
6516 if ($testing_data !== null) {
6517 $error_response['testing_data'] = $testing_data;
6518 }
6519 return $error_response;
6520 }
6521
6522 // Check if web search is enabled (default case also handles OpenAI models)
6523 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6524 $unsupported_web_search_models = array('gpt-4.1-nano');
6525 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6526
6527 if ($web_search_enabled && $model_supports_web_search) {
6528 return $this->mxchat_generate_response_openai_web_search(
6529 $selected_model,
6530 $api_key,
6531 $conversation_history,
6532 $relevant_content,
6533 $session_id,
6534 $testing_data,
6535 $streaming
6536 );
6537 } elseif ($streaming) {
6538 return $this->mxchat_generate_response_openai_stream(
6539 $selected_model,
6540 $api_key,
6541 $conversation_history,
6542 $relevant_content,
6543 $session_id,
6544 $testing_data
6545 );
6546 } else {
6547 $response = $this->mxchat_generate_response_openai(
6548 $selected_model,
6549 $api_key,
6550 $conversation_history,
6551 $relevant_content
6552 );
6553 }
6554 break;
6555 }
6556
6557 if (is_array($response) && isset($response['error'])) {
6558 if ($testing_data !== null) {
6559 $response['testing_data'] = $testing_data;
6560 }
6561 return $response;
6562 }
6563
6564 return $response;
6565
6566 } catch (Exception $e) {
6567 $error_response = [
6568 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
6569 'error_code' => 'system_exception',
6570 'exception_details' => $e->getMessage()
6571 ];
6572
6573 if ($testing_data !== null) {
6574 $error_response['testing_data'] = $testing_data;
6575 }
6576
6577 return $error_response;
6578 }
6579 }
6580 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6581 try {
6582 $bot_id = $this->get_current_bot_id($session_id);
6583 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6584
6585 if (!is_array($conversation_history)) {
6586 $conversation_history = array();
6587 }
6588
6589 $formatted_conversation = array();
6590
6591 $formatted_conversation[] = array(
6592 'role' => 'system',
6593 'content' => $system_prompt_instructions . " " . $relevant_content
6594 );
6595
6596 foreach ($conversation_history as $message) {
6597 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6598 $role = $message['role'];
6599 if ($role === 'bot' || $role === 'agent') {
6600 $role = 'assistant';
6601 }
6602 if (!in_array($role, ['system', 'assistant', 'user'])) {
6603 $role = 'user';
6604 }
6605 $formatted_conversation[] = array(
6606 'role' => $role,
6607 'content' => $message['content']
6608 );
6609 }
6610 }
6611
6612 if (headers_sent() || !function_exists('curl_init')) {
6613 $regular_response = $this->mxchat_generate_response_openrouter(
6614 $selected_model,
6615 $openrouter_api_key,
6616 $conversation_history,
6617 $relevant_content
6618 );
6619
6620 // Save bot response to transcript
6621 if (!empty($regular_response) && !empty($session_id)) {
6622 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6623 }
6624
6625 $response_data = [
6626 'text' => $regular_response,
6627 'html' => '',
6628 'session_id' => $session_id
6629 ];
6630
6631 if ($testing_data !== null) {
6632 $response_data['testing_data'] = $testing_data;
6633 }
6634
6635 header('Content-Type: application/json');
6636 echo json_encode($response_data);
6637 return true;
6638 }
6639
6640 $body = json_encode([
6641 'model' => $selected_model,
6642 'messages' => $formatted_conversation,
6643 'temperature' => 1,
6644 'stream' => true
6645 ]);
6646
6647 // Setup streaming headers now that we know we're actually streaming
6648 $this->setup_streaming_headers();
6649
6650 $ch = curl_init();
6651 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
6652 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6653 curl_setopt($ch, CURLOPT_POST, true);
6654 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6655 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6656 'Content-Type: application/json',
6657 'Authorization: Bearer ' . $openrouter_api_key,
6658 'HTTP-Referer: ' . home_url(),
6659 'X-Title: ' . get_bloginfo('name')
6660 ));
6661 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6662 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6663
6664 $full_response = '';
6665 $stream_started = false;
6666 $buffer = '';
6667
6668 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6669 if (!$stream_started && $testing_data !== null) {
6670 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6671 flush();
6672 $stream_started = true;
6673 }
6674
6675 $buffer .= $data;
6676 $lines = explode("\n", $buffer);
6677 $buffer = array_pop($lines);
6678
6679 foreach ($lines as $line) {
6680 if (trim($line) === '') {
6681 continue;
6682 }
6683
6684 if (strpos($line, 'data: ') !== 0) {
6685 continue;
6686 }
6687
6688 $json_str = substr($line, 6);
6689
6690 if (trim($json_str) === '[DONE]') {
6691 echo "data: [DONE]\n\n";
6692 flush();
6693 continue;
6694 }
6695
6696 $json = json_decode(trim($json_str), true);
6697 if ($json && isset($json['choices'][0]['delta']['content'])) {
6698 $content = $json['choices'][0]['delta']['content'];
6699 $full_response .= $content;
6700
6701 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6702 flush();
6703 }
6704 }
6705
6706 return strlen($data);
6707 });
6708
6709 $response = curl_exec($ch);
6710 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6711
6712 if (curl_errno($ch) || $http_code !== 200) {
6713 curl_close($ch);
6714
6715 $regular_response = $this->mxchat_generate_response_openrouter(
6716 $selected_model,
6717 $openrouter_api_key,
6718 $conversation_history,
6719 $relevant_content
6720 );
6721
6722 $response_data = [
6723 'text' => $regular_response,
6724 'html' => '',
6725 'session_id' => $session_id
6726 ];
6727
6728 if ($testing_data !== null) {
6729 $response_data['testing_data'] = $testing_data;
6730 }
6731
6732 header('Content-Type: application/json');
6733 echo json_encode($response_data);
6734 return true;
6735 }
6736
6737 curl_close($ch);
6738
6739 if (!empty($full_response) && !empty($session_id)) {
6740 // Prepare RAG context for streaming response
6741 $rag_context_for_storage = null;
6742 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
6743 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
6744
6745 if ($has_rag_data || $has_action_data) {
6746 $rag_context_for_storage = [];
6747
6748 if ($has_rag_data) {
6749 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
6750 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
6751 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
6752 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
6753 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
6754 }
6755
6756 if ($has_action_data) {
6757 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
6758 }
6759 }
6760 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6761 }
6762
6763 return true;
6764
6765 } catch (Exception $e) {
6766 $regular_response = $this->mxchat_generate_response_openrouter(
6767 $selected_model,
6768 $openrouter_api_key,
6769 $conversation_history,
6770 $relevant_content
6771 );
6772
6773 $response_data = [
6774 'text' => $regular_response,
6775 'html' => '',
6776 'session_id' => $session_id
6777 ];
6778
6779 if ($testing_data !== null) {
6780 $response_data['testing_data'] = $testing_data;
6781 }
6782
6783 header('Content-Type: application/json');
6784 echo json_encode($response_data);
6785 return true;
6786 }
6787 }
6788 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6789 try {
6790 $bot_id = $this->get_current_bot_id($session_id);
6791
6792 // Get system prompt instructions using centralized function
6793 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6794
6795 // Ensure conversation_history is an array
6796 if (!is_array($conversation_history)) {
6797 $conversation_history = array();
6798 }
6799
6800 // Format conversation history for OpenAI
6801 $formatted_conversation = array();
6802
6803 $formatted_conversation[] = array(
6804 'role' => 'system',
6805 'content' => $system_prompt_instructions . " " . $relevant_content
6806 );
6807
6808 foreach ($conversation_history as $message) {
6809 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6810 $role = $message['role'];
6811 if ($role === 'bot' || $role === 'agent') {
6812 $role = 'assistant';
6813 }
6814 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
6815 $role = 'user';
6816 }
6817 $formatted_conversation[] = array(
6818 'role' => $role,
6819 'content' => $message['content']
6820 );
6821 }
6822 }
6823
6824 // Check if we can actually stream
6825 if (headers_sent() || !function_exists('curl_init')) {
6826 // Fallback to regular response with testing data
6827 $regular_response = $this->mxchat_generate_response_openai(
6828 $selected_model,
6829 $api_key,
6830 $conversation_history,
6831 $relevant_content
6832 );
6833
6834 // Save bot response to transcript
6835 if (!empty($regular_response) && !empty($session_id)) {
6836 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6837 }
6838
6839 $response_data = [
6840 'text' => $regular_response,
6841 'html' => '',
6842 'session_id' => $session_id
6843 ];
6844
6845 if ($testing_data !== null) {
6846 $response_data['testing_data'] = $testing_data;
6847 }
6848
6849 header('Content-Type: application/json');
6850 echo json_encode($response_data);
6851 return true;
6852 }
6853
6854 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
6855 $is_gpt5_model = (
6856 strpos($selected_model, 'gpt-5') === 0 ||
6857 $selected_model === 'gpt-5.2' ||
6858 $selected_model === 'gpt-5.1-2025-11-13' ||
6859 $selected_model === 'gpt-5' ||
6860 $selected_model === 'gpt-5-mini' ||
6861 $selected_model === 'gpt-5-nano'
6862 );
6863
6864 // Build request body with optimal settings for fast streaming
6865 $request_body = [
6866 'model' => $selected_model,
6867 'messages' => $formatted_conversation,
6868 'temperature' => 1,
6869 'stream' => true
6870 ];
6871
6872 // Add reasoning_effort only for GPT-5 models that support it
6873 // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
6874 if ($is_gpt5_model && $selected_model !== 'gpt-5.2' && $selected_model !== 'gpt-5.1-chat-latest') {
6875 // GPT-5.1 uses 'low' instead of 'minimal'
6876 if ($selected_model === 'gpt-5.1-2025-11-13') {
6877 $request_body['reasoning_effort'] = 'low';
6878 } else {
6879 $request_body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
6880 }
6881 }
6882
6883 $body = json_encode($request_body);
6884
6885 // Setup streaming headers now that we know we're actually streaming
6886 $this->setup_streaming_headers();
6887
6888 // Use cURL for streaming support
6889 $ch = curl_init();
6890 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
6891 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6892 curl_setopt($ch, CURLOPT_POST, true);
6893 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6894 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6895 'Content-Type: application/json',
6896 'Authorization: Bearer ' . $api_key
6897 ));
6898 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6899 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6900
6901 $full_response = ''; // Accumulate full response for saving
6902 $stream_started = false;
6903 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
6904
6905 // Buffer control for real-time streaming
6906 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6907 // Send testing data as the first event if available
6908 if (!$stream_started && $testing_data !== null) {
6909 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6910 flush();
6911 $stream_started = true;
6912 }
6913
6914 // CRITICAL FIX: Append new data to buffer
6915 $buffer .= $data;
6916
6917 // Process complete lines only
6918 $lines = explode("\n", $buffer);
6919
6920 // CRITICAL FIX: Keep the last incomplete line in the buffer
6921 // The last element might be incomplete, so keep it in buffer
6922 $buffer = array_pop($lines);
6923
6924 foreach ($lines as $line) {
6925 // Skip empty lines
6926 if (trim($line) === '') {
6927 continue;
6928 }
6929
6930 // Only process lines that start with "data: "
6931 if (strpos($line, 'data: ') !== 0) {
6932 continue;
6933 }
6934
6935 $json_str = substr($line, 6); // Remove 'data: ' prefix
6936
6937 if (trim($json_str) === '[DONE]') {
6938 echo "data: [DONE]\n\n";
6939 flush();
6940 continue;
6941 }
6942
6943 // Try to decode JSON
6944 $json = json_decode(trim($json_str), true);
6945 if ($json && isset($json['choices'][0]['delta']['content'])) {
6946 $content = $json['choices'][0]['delta']['content'];
6947 $full_response .= $content; // Accumulate the full response
6948
6949 // Send as SSE format
6950 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6951 flush();
6952 }
6953 }
6954
6955 return strlen($data);
6956 });
6957
6958 $response = curl_exec($ch);
6959 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6960
6961 if (curl_errno($ch) || $http_code !== 200) {
6962 $curl_error = curl_error($ch);
6963 curl_close($ch);
6964
6965 // Fallback to regular response
6966 $regular_response = $this->mxchat_generate_response_openai(
6967 $selected_model,
6968 $api_key,
6969 $conversation_history,
6970 $relevant_content
6971 );
6972
6973 // FIXED: Check if regular response returned an error
6974 if (is_array($regular_response) && isset($regular_response['error'])) {
6975 // Send error in SSE format since we're in streaming mode
6976 echo "data: " . json_encode([
6977 'error' => true,
6978 'error_message' => $regular_response['error'],
6979 'error_code' => $regular_response['error_code'] ?? 'api_error',
6980 'text' => $regular_response['error'],
6981 'message' => $regular_response['error']
6982 ]) . "\n\n";
6983 echo "data: [DONE]\n\n";
6984 flush();
6985 return true;
6986 }
6987
6988 $response_data = [
6989 'text' => $regular_response,
6990 'html' => '',
6991 'session_id' => $session_id
6992 ];
6993
6994 if ($testing_data !== null) {
6995 $response_data['testing_data'] = $testing_data;
6996 }
6997
6998 header('Content-Type: application/json');
6999 echo json_encode($response_data);
7000 return true;
7001 }
7002
7003 curl_close($ch);
7004
7005 // Save the complete response to maintain chat persistence
7006 if (!empty($full_response) && !empty($session_id)) {
7007 // Prepare RAG context for streaming response
7008 $rag_context_for_storage = null;
7009 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7010 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7011
7012 if ($has_rag_data || $has_action_data) {
7013 $rag_context_for_storage = [];
7014
7015 if ($has_rag_data) {
7016 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7017 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7018 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7019 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7020 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7021 }
7022
7023 if ($has_action_data) {
7024 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7025 }
7026 }
7027 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7028 }
7029
7030 return true; // Indicate streaming completed successfully
7031
7032 } catch (Exception $e) {
7033 // Fallback to regular response
7034 $regular_response = $this->mxchat_generate_response_openai(
7035 $selected_model,
7036 $api_key,
7037 $conversation_history,
7038 $relevant_content
7039 );
7040
7041 // FIXED: Check if regular response returned an error
7042 if (is_array($regular_response) && isset($regular_response['error'])) {
7043 // Send error in SSE format since we're in streaming mode
7044 echo "data: " . json_encode([
7045 'error' => true,
7046 'error_message' => $regular_response['error'],
7047 'error_code' => $regular_response['error_code'] ?? 'api_error',
7048 'text' => $regular_response['error'],
7049 'message' => $regular_response['error']
7050 ]) . "\n\n";
7051 echo "data: [DONE]\n\n";
7052 flush();
7053 return true;
7054 }
7055
7056 $response_data = [
7057 'text' => $regular_response,
7058 'html' => '',
7059 'session_id' => $session_id
7060 ];
7061
7062 if ($testing_data !== null) {
7063 $response_data['testing_data'] = $testing_data;
7064 }
7065
7066 header('Content-Type: application/json');
7067 echo json_encode($response_data);
7068 return true;
7069 }
7070 }
7071
7072 /**
7073 * Generate response using OpenAI Responses API with web search tool
7074 * This uses the newer Responses API which supports web search functionality
7075 */
7076 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
7077 try {
7078 $bot_id = $this->get_current_bot_id($session_id);
7079 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7080
7081 if (!is_array($conversation_history)) {
7082 $conversation_history = array();
7083 }
7084
7085 // Build the input for Responses API
7086 // The Responses API uses a different format - we need to construct the input properly
7087 $input_parts = [];
7088
7089 // Add system instructions as context
7090 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
7091
7092 // Build conversation as input items for Responses API
7093 foreach ($conversation_history as $message) {
7094 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7095 $role = $message['role'];
7096 if ($role === 'bot' || $role === 'agent') {
7097 $role = 'assistant';
7098 }
7099 if (!in_array($role, ['assistant', 'user'])) {
7100 $role = 'user';
7101 }
7102 $input_parts[] = [
7103 'type' => 'message',
7104 'role' => $role,
7105 'content' => $message['content']
7106 ];
7107 }
7108 }
7109
7110 // Build request body for Responses API with web search
7111 $request_body = [
7112 'model' => $selected_model,
7113 'input' => $input_parts,
7114 'instructions' => $system_context,
7115 'tools' => [
7116 ['type' => 'web_search']
7117 ],
7118 'stream' => $streaming
7119 ];
7120
7121 // Add reasoning effort for supported models (not for gpt-5 with minimal which doesn't support web search)
7122 // Per OpenAI docs: web search is not supported with gpt-5 minimal reasoning
7123 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
7124 if ($is_gpt5_model && $selected_model !== 'gpt-5.2') {
7125 // Use 'low' for GPT-5.1, skip for others to avoid 'minimal' which doesn't support web search
7126 if ($selected_model === 'gpt-5.1-2025-11-13') {
7127 $request_body['reasoning'] = ['effort' => 'low'];
7128 }
7129 // For other GPT-5 models, don't set reasoning to allow web search
7130 }
7131
7132 error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
7133
7134 if ($streaming) {
7135 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
7136 } else {
7137 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7138 }
7139
7140 } catch (Exception $e) {
7141 error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
7142 return [
7143 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
7144 'error_code' => 'web_search_exception'
7145 ];
7146 }
7147 }
7148
7149 /**
7150 * Handle non-streaming web search response
7151 */
7152 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7153 $request_body['stream'] = false;
7154
7155 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
7156 'headers' => array(
7157 'Authorization' => 'Bearer ' . $api_key,
7158 'Content-Type' => 'application/json'
7159 ),
7160 'body' => json_encode($request_body),
7161 'timeout' => 90 // Web search can take longer
7162 ));
7163
7164 if (is_wp_error($response)) {
7165 error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
7166 return [
7167 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
7168 'error_code' => 'web_search_connection_error'
7169 ];
7170 }
7171
7172 $response_code = wp_remote_retrieve_response_code($response);
7173 $response_body = wp_remote_retrieve_body($response);
7174
7175 error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
7176 error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
7177
7178 if ($response_code !== 200) {
7179 $error_data = json_decode($response_body, true);
7180 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
7181 return [
7182 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
7183 'error_code' => 'web_search_api_error'
7184 ];
7185 }
7186
7187 $result = json_decode($response_body, true);
7188
7189 if (json_last_error() !== JSON_ERROR_NONE) {
7190 return [
7191 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
7192 'error_code' => 'web_search_json_error'
7193 ];
7194 }
7195
7196 // Extract the response text and citations from Responses API format
7197 $output_text = '';
7198 $citations = [];
7199
7200 if (isset($result['output'])) {
7201 foreach ($result['output'] as $output_item) {
7202 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
7203 foreach ($output_item['content'] as $content_item) {
7204 if ($content_item['type'] === 'output_text') {
7205 $output_text .= $content_item['text'];
7206
7207 // Extract citations/annotations
7208 if (isset($content_item['annotations'])) {
7209 foreach ($content_item['annotations'] as $annotation) {
7210 if ($annotation['type'] === 'url_citation') {
7211 $citations[] = [
7212 'url' => $annotation['url'],
7213 'title' => $annotation['title'] ?? ''
7214 ];
7215 }
7216 }
7217 }
7218 }
7219 }
7220 }
7221 }
7222 }
7223
7224 // If we have citations, append them to the response
7225 if (!empty($citations)) {
7226 $output_text .= "\n\n**Sources:**\n";
7227 $seen_urls = [];
7228 foreach ($citations as $citation) {
7229 if (!in_array($citation['url'], $seen_urls)) {
7230 $seen_urls[] = $citation['url'];
7231 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7232 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7233 }
7234 }
7235 }
7236
7237 // Save to transcript
7238 if (!empty($output_text) && !empty($session_id)) {
7239 $this->mxchat_save_chat_message($session_id, 'bot', $output_text);
7240 }
7241
7242 return $output_text;
7243 }
7244
7245 /**
7246 * Handle streaming web search response using Responses API
7247 */
7248 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7249 $request_body['stream'] = true;
7250
7251 // Check if we can stream
7252 if (headers_sent() || !function_exists('curl_init')) {
7253 // Fallback to non-streaming
7254 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7255 }
7256
7257 // Setup streaming headers
7258 $this->setup_streaming_headers();
7259
7260 $ch = curl_init();
7261 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
7262 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7263 curl_setopt($ch, CURLOPT_POST, true);
7264 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
7265 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7266 'Content-Type: application/json',
7267 'Authorization: Bearer ' . $api_key
7268 ));
7269 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7270 curl_setopt($ch, CURLOPT_TIMEOUT, 120); // Web search can take longer
7271
7272 $full_response = '';
7273 $stream_started = false;
7274 $buffer = '';
7275 $citations = [];
7276
7277 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
7278 // Send testing data as first event if available
7279 if (!$stream_started && $testing_data !== null) {
7280 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7281 flush();
7282 $stream_started = true;
7283 }
7284
7285 $buffer .= $data;
7286 $lines = explode("\n", $buffer);
7287 $buffer = array_pop($lines);
7288
7289 foreach ($lines as $line) {
7290 if (trim($line) === '') continue;
7291 if (strpos($line, 'data: ') !== 0) continue;
7292
7293 $json_str = substr($line, 6);
7294
7295 if (trim($json_str) === '[DONE]') {
7296 // Append citations if we have any
7297 if (!empty($citations)) {
7298 $citation_text = "\n\n**Sources:**\n";
7299 $seen_urls = [];
7300 foreach ($citations as $citation) {
7301 if (!in_array($citation['url'], $seen_urls)) {
7302 $seen_urls[] = $citation['url'];
7303 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7304 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7305 }
7306 }
7307 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
7308 $full_response .= $citation_text;
7309 flush();
7310 }
7311 echo "data: [DONE]\n\n";
7312 flush();
7313 continue;
7314 }
7315
7316 $json = json_decode(trim($json_str), true);
7317 if (!$json) continue;
7318
7319 // Handle Responses API streaming events
7320 // The format is different from Chat Completions
7321 if (isset($json['type'])) {
7322 switch ($json['type']) {
7323 case 'response.output_text.delta':
7324 // Text content delta
7325 if (isset($json['delta'])) {
7326 $content = $json['delta'];
7327 $full_response .= $content;
7328 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7329 flush();
7330 }
7331 break;
7332
7333 case 'response.output_item.done':
7334 // Check for citations in completed items
7335 if (isset($json['item']['content'])) {
7336 foreach ($json['item']['content'] as $content_item) {
7337 if (isset($content_item['annotations'])) {
7338 foreach ($content_item['annotations'] as $annotation) {
7339 if ($annotation['type'] === 'url_citation') {
7340 $citations[] = [
7341 'url' => $annotation['url'],
7342 'title' => $annotation['title'] ?? ''
7343 ];
7344 }
7345 }
7346 }
7347 }
7348 }
7349 break;
7350 }
7351 }
7352 }
7353
7354 return strlen($data);
7355 });
7356
7357 $response = curl_exec($ch);
7358 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7359
7360 if (curl_errno($ch) || $http_code !== 200) {
7361 $curl_error = curl_error($ch);
7362 curl_close($ch);
7363
7364 error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
7365
7366 // Fallback to non-streaming
7367 $fallback_response = $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7368
7369 if (is_array($fallback_response) && isset($fallback_response['error'])) {
7370 echo "data: " . json_encode([
7371 'error' => true,
7372 'error_message' => $fallback_response['error'],
7373 'error_code' => $fallback_response['error_code'] ?? 'web_search_error'
7374 ]) . "\n\n";
7375 echo "data: [DONE]\n\n";
7376 flush();
7377 return true;
7378 }
7379
7380 $response_data = [
7381 'text' => $fallback_response,
7382 'html' => '',
7383 'session_id' => $session_id
7384 ];
7385 if ($testing_data !== null) {
7386 $response_data['testing_data'] = $testing_data;
7387 }
7388 header('Content-Type: application/json');
7389 echo json_encode($response_data);
7390 return true;
7391 }
7392
7393 curl_close($ch);
7394
7395 // Save the complete response
7396 if (!empty($full_response) && !empty($session_id)) {
7397 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
7398 }
7399
7400 return true;
7401 }
7402
7403 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7404 try {
7405 // Get bot ID from session or request
7406 $bot_id = $this->get_current_bot_id($session_id);
7407
7408 // Get system prompt instructions using centralized function
7409 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7410 // Ensure conversation_history is an array
7411 if (!is_array($conversation_history)) {
7412 $conversation_history = array();
7413 }
7414
7415 // Clean and validate conversation history
7416 foreach ($conversation_history as &$message) {
7417 // Convert bot and agent roles to assistant
7418 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7419 $message['role'] = 'assistant';
7420 }
7421
7422 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7423 if (!in_array($message['role'], ['assistant', 'user'])) {
7424 $message['role'] = 'user';
7425 }
7426
7427 // Ensure content field exists
7428 if (!isset($message['content']) || empty($message['content'])) {
7429 $message['content'] = '';
7430 }
7431
7432 // Remove any unsupported fields
7433 $message = array_intersect_key($message, array_flip(['role', 'content']));
7434 }
7435
7436 // Add relevant content as the latest user message
7437 $conversation_history[] = [
7438 'role' => 'user',
7439 'content' => $relevant_content
7440 ];
7441
7442 // Prepare the request body with stream: true
7443 $body = json_encode([
7444 'model' => $selected_model,
7445 'messages' => $conversation_history,
7446 'max_tokens' => 1000,
7447 'temperature' => 0.8,
7448 'system' => $system_prompt_instructions,
7449 'stream' => true
7450 ]);
7451
7452 // Check if we can actually stream (headers not sent, etc.)
7453 if (headers_sent() || !function_exists('curl_init')) {
7454 // Fallback to regular response with testing data
7455 //error_log("MxChat: Streaming not possible, falling back to regular response");
7456 $regular_response = $this->mxchat_generate_response_claude(
7457 $selected_model,
7458 $claude_api_key,
7459 array_slice($conversation_history, 0, -1), // Remove the added content
7460 $relevant_content
7461 );
7462
7463 // Save bot response to transcript
7464 if (!empty($regular_response) && !empty($session_id)) {
7465 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7466 }
7467
7468 // Return as JSON with testing data
7469 $response_data = [
7470 'text' => $regular_response,
7471 'html' => '',
7472 'session_id' => $session_id
7473 ];
7474
7475 if ($testing_data !== null) {
7476 $response_data['testing_data'] = $testing_data;
7477 //error_log("MxChat Testing: Added testing data to Claude fallback response");
7478 }
7479
7480 // Clear any streaming headers and send JSON
7481 if (headers_sent() === false) {
7482 header('Content-Type: application/json');
7483 }
7484 echo json_encode($response_data);
7485 return true; // Indicate we handled the response
7486 }
7487
7488 // Setup streaming headers now that we know we're actually streaming
7489 $this->setup_streaming_headers();
7490
7491 // Use cURL for streaming support
7492 $ch = curl_init();
7493 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
7494 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7495 curl_setopt($ch, CURLOPT_POST, true);
7496 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7497 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7498 'Content-Type: application/json',
7499 'x-api-key: ' . $claude_api_key,
7500 'anthropic-version: 2023-06-01'
7501 ));
7502 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7503 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7504
7505 $full_response = ''; // Accumulate full response for saving
7506 $stream_started = false;
7507 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7508
7509 // Buffer control for real-time streaming
7510 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7511 // Send testing data as the first event if available
7512 if (!$stream_started && $testing_data !== null) {
7513 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7514 flush();
7515 $stream_started = true;
7516 //error_log("MxChat Testing: Sent testing data in Claude stream");
7517 }
7518
7519 // CRITICAL FIX: Append new data to buffer
7520 $buffer .= $data;
7521
7522 // Process complete lines only
7523 $lines = explode("\n", $buffer);
7524
7525 // CRITICAL FIX: Keep the last incomplete line in the buffer
7526 // The last element might be incomplete, so keep it in buffer
7527 $buffer = array_pop($lines);
7528
7529 foreach ($lines as $line) {
7530 if (trim($line) === '') {
7531 continue;
7532 }
7533
7534 // Claude uses event: and data: format
7535 if (strpos($line, 'event: ') === 0) {
7536 // Store the event type for the next data line
7537 continue;
7538 }
7539
7540 if (strpos($line, 'data: ') === 0) {
7541 $json_str = substr($line, 6); // Remove 'data: ' prefix
7542
7543 $json = json_decode(trim($json_str), true);
7544 if (json_last_error() !== JSON_ERROR_NONE) {
7545 continue;
7546 }
7547
7548 // Handle different event types
7549 if (isset($json['type'])) {
7550 switch ($json['type']) {
7551 case 'content_block_delta':
7552 if (isset($json['delta']['text'])) {
7553 $content = $json['delta']['text'];
7554 $full_response .= $content; // Accumulate
7555 // Send as SSE format compatible with your frontend
7556 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7557 flush();
7558 }
7559 break;
7560
7561 case 'message_stop':
7562 echo "data: [DONE]\n\n";
7563 flush();
7564 break;
7565
7566 case 'error':
7567 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
7568 flush();
7569 break;
7570 }
7571 }
7572 }
7573 }
7574
7575 return strlen($data);
7576 });
7577
7578 $response = curl_exec($ch);
7579 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7580
7581 if (curl_errno($ch)) {
7582 curl_close($ch);
7583 throw new Exception('cURL Error: ' . curl_error($ch));
7584 }
7585
7586 curl_close($ch);
7587
7588 if ($http_code !== 200) {
7589 // Fallback to regular response
7590 //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
7591 $regular_response = $this->mxchat_generate_response_claude(
7592 $selected_model,
7593 $claude_api_key,
7594 array_slice($conversation_history, 0, -1), // Remove the added content
7595 $relevant_content
7596 );
7597
7598 // FIXED: Check if regular response returned an error
7599 if (is_array($regular_response) && isset($regular_response['error'])) {
7600 // Send error in SSE format since we're in streaming mode
7601 echo "data: " . json_encode([
7602 'error' => true,
7603 'error_message' => $regular_response['error'],
7604 'error_code' => $regular_response['error_code'] ?? 'api_error',
7605 'text' => $regular_response['error'],
7606 'message' => $regular_response['error']
7607 ]) . "\n\n";
7608 echo "data: [DONE]\n\n";
7609 flush();
7610 return true;
7611 }
7612
7613 $response_data = [
7614 'text' => $regular_response,
7615 'html' => '',
7616 'session_id' => $session_id
7617 ];
7618
7619 if ($testing_data !== null) {
7620 $response_data['testing_data'] = $testing_data;
7621 //error_log("MxChat Testing: Added testing data to Claude error fallback");
7622 }
7623
7624 header('Content-Type: application/json');
7625 echo json_encode($response_data);
7626 return true;
7627 }
7628
7629 // Save the complete response to maintain chat persistence
7630 if (!empty($full_response) && !empty($session_id)) {
7631 // Prepare RAG context for streaming response
7632 $rag_context_for_storage = null;
7633 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7634 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7635
7636 if ($has_rag_data || $has_action_data) {
7637 $rag_context_for_storage = [];
7638
7639 if ($has_rag_data) {
7640 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7641 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7642 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7643 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7644 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7645 }
7646
7647 if ($has_action_data) {
7648 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7649 }
7650 }
7651 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7652 }
7653
7654 return true; // Indicate streaming completed successfully
7655
7656 } catch (Exception $e) {
7657 //error_log("MxChat Claude streaming exception: " . $e->getMessage());
7658
7659 // Fallback to regular response on exception
7660 $regular_response = $this->mxchat_generate_response_claude(
7661 $selected_model,
7662 $claude_api_key,
7663 $conversation_history,
7664 $relevant_content
7665 );
7666
7667 // FIXED: Check if regular response returned an error
7668 if (is_array($regular_response) && isset($regular_response['error'])) {
7669 // Send error in SSE format since we're in streaming mode
7670 echo "data: " . json_encode([
7671 'error' => true,
7672 'error_message' => $regular_response['error'],
7673 'error_code' => $regular_response['error_code'] ?? 'api_error',
7674 'text' => $regular_response['error'],
7675 'message' => $regular_response['error']
7676 ]) . "\n\n";
7677 echo "data: [DONE]\n\n";
7678 flush();
7679 return true;
7680 }
7681
7682 $response_data = [
7683 'text' => $regular_response,
7684 'html' => '',
7685 'session_id' => $session_id
7686 ];
7687
7688 if ($testing_data !== null) {
7689 $response_data['testing_data'] = $testing_data;
7690 //error_log("MxChat Testing: Added testing data to Claude exception fallback");
7691 }
7692
7693 header('Content-Type: application/json');
7694 echo json_encode($response_data);
7695 return true;
7696 }
7697 }
7698 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7699 try {
7700 // Get bot ID from session or request
7701 $bot_id = $this->get_current_bot_id($session_id);
7702
7703 // Get system prompt instructions using centralized function
7704 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7705
7706 // Ensure conversation_history is an array
7707 if (!is_array($conversation_history)) {
7708 $conversation_history = array();
7709 }
7710
7711 // Format conversation history for X.AI (same as OpenAI format)
7712 $formatted_conversation = array();
7713
7714 $formatted_conversation[] = array(
7715 'role' => 'system',
7716 'content' => $system_prompt_instructions . " " . $relevant_content
7717 );
7718
7719 foreach ($conversation_history as $message) {
7720 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7721 $role = $message['role'];
7722 if ($role === 'bot' || $role === 'agent') {
7723 $role = 'assistant';
7724 }
7725 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7726 $role = 'user';
7727 }
7728 $formatted_conversation[] = array(
7729 'role' => $role,
7730 'content' => $message['content']
7731 );
7732 }
7733 }
7734
7735 // Check if we can actually stream
7736 if (headers_sent() || !function_exists('curl_init')) {
7737 // Fallback to regular response with testing data
7738 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
7739 $regular_response = $this->mxchat_generate_response_xai(
7740 $selected_model,
7741 $xai_api_key,
7742 $conversation_history,
7743 $relevant_content
7744 );
7745
7746 // Save bot response to transcript
7747 if (!empty($regular_response) && !empty($session_id)) {
7748 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7749 }
7750
7751 $response_data = [
7752 'text' => $regular_response,
7753 'html' => '',
7754 'session_id' => $session_id
7755 ];
7756
7757 if ($testing_data !== null) {
7758 $response_data['testing_data'] = $testing_data;
7759 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
7760 }
7761
7762 header('Content-Type: application/json');
7763 echo json_encode($response_data);
7764 return true;
7765 }
7766
7767 // Prepare the request body with stream: true
7768 $body = json_encode([
7769 'model' => $selected_model,
7770 'messages' => $formatted_conversation,
7771 'temperature' => 0.8,
7772 'stream' => true
7773 ]);
7774
7775 // Setup streaming headers now that we know we're actually streaming
7776 $this->setup_streaming_headers();
7777
7778 // Use cURL for streaming support
7779 $ch = curl_init();
7780 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
7781 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7782 curl_setopt($ch, CURLOPT_POST, true);
7783 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7784 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7785 'Content-Type: application/json',
7786 'Authorization: Bearer ' . $xai_api_key
7787 ));
7788 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7789 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7790
7791 $full_response = ''; // Accumulate full response for saving
7792 $stream_started = false;
7793 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7794
7795 // Buffer control for real-time streaming
7796 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7797 // Send testing data as the first event if available
7798 if (!$stream_started && $testing_data !== null) {
7799 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7800 flush();
7801 $stream_started = true;
7802 //error_log("MxChat Testing: Sent testing data in X.AI stream");
7803 }
7804
7805 // CRITICAL FIX: Append new data to buffer
7806 $buffer .= $data;
7807
7808 // Process complete lines only
7809 $lines = explode("\n", $buffer);
7810
7811 // CRITICAL FIX: Keep the last incomplete line in the buffer
7812 // The last element might be incomplete, so keep it in buffer
7813 $buffer = array_pop($lines);
7814
7815 foreach ($lines as $line) {
7816 // Skip empty lines
7817 if (trim($line) === '') {
7818 continue;
7819 }
7820
7821 // Only process lines that start with "data: "
7822 if (strpos($line, 'data: ') !== 0) {
7823 continue;
7824 }
7825
7826 $json_str = substr($line, 6); // Remove 'data: ' prefix
7827
7828 if (trim($json_str) === '[DONE]') {
7829 echo "data: [DONE]\n\n";
7830 flush();
7831 continue;
7832 }
7833
7834 // Try to decode JSON
7835 $json = json_decode(trim($json_str), true);
7836 if ($json && isset($json['choices'][0]['delta']['content'])) {
7837 $content = $json['choices'][0]['delta']['content'];
7838 $full_response .= $content; // Accumulate
7839 // Send as SSE format
7840 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7841 flush();
7842 }
7843 }
7844
7845 return strlen($data);
7846 });
7847
7848 $response = curl_exec($ch);
7849 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7850
7851 if (curl_errno($ch) || $http_code !== 200) {
7852 curl_close($ch);
7853
7854 // Fallback to regular response
7855 //error_log("MxChat: X.AI streaming failed, falling back");
7856 $regular_response = $this->mxchat_generate_response_xai(
7857 $selected_model,
7858 $xai_api_key,
7859 $conversation_history,
7860 $relevant_content
7861 );
7862
7863 $response_data = [
7864 'text' => $regular_response,
7865 'html' => '',
7866 'session_id' => $session_id
7867 ];
7868
7869 if ($testing_data !== null) {
7870 $response_data['testing_data'] = $testing_data;
7871 //error_log("MxChat Testing: Added testing data to X.AI error fallback");
7872 }
7873
7874 header('Content-Type: application/json');
7875 echo json_encode($response_data);
7876 return true;
7877 }
7878
7879 curl_close($ch);
7880
7881 // Save the complete response to maintain chat persistence
7882 if (!empty($full_response) && !empty($session_id)) {
7883 // Prepare RAG context for streaming response
7884 $rag_context_for_storage = null;
7885 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7886 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7887
7888 if ($has_rag_data || $has_action_data) {
7889 $rag_context_for_storage = [];
7890
7891 if ($has_rag_data) {
7892 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7893 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7894 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7895 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7896 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7897 }
7898
7899 if ($has_action_data) {
7900 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7901 }
7902 }
7903 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7904 }
7905
7906 return true; // Indicate streaming completed successfully
7907
7908 } catch (Exception $e) {
7909 //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
7910
7911 // Fallback to regular response
7912 $regular_response = $this->mxchat_generate_response_xai(
7913 $selected_model,
7914 $xai_api_key,
7915 $conversation_history,
7916 $relevant_content
7917 );
7918
7919 $response_data = [
7920 'text' => $regular_response,
7921 'html' => '',
7922 'session_id' => $session_id
7923 ];
7924
7925 if ($testing_data !== null) {
7926 $response_data['testing_data'] = $testing_data;
7927 //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
7928 }
7929
7930 header('Content-Type: application/json');
7931 echo json_encode($response_data);
7932 return true;
7933 }
7934 }
7935 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7936 try {
7937 // Get bot ID from session or request
7938 $bot_id = $this->get_current_bot_id($session_id);
7939
7940 // Get system prompt instructions using centralized function
7941 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7942
7943 // Ensure conversation_history is an array
7944 if (!is_array($conversation_history)) {
7945 $conversation_history = array();
7946 }
7947
7948 // Format conversation history for DeepSeek
7949 $formatted_conversation = array();
7950
7951 $formatted_conversation[] = array(
7952 'role' => 'system',
7953 'content' => $system_prompt_instructions . " " . $relevant_content
7954 );
7955
7956 foreach ($conversation_history as $message) {
7957 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7958 $role = $message['role'];
7959 if ($role === 'bot' || $role === 'agent') {
7960 $role = 'assistant';
7961 }
7962 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7963 $role = 'user';
7964 }
7965 $formatted_conversation[] = array(
7966 'role' => $role,
7967 'content' => $message['content']
7968 );
7969 }
7970 }
7971
7972 // Check if we can actually stream
7973 if (headers_sent() || !function_exists('curl_init')) {
7974 // Fallback to regular response with testing data
7975 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
7976 $regular_response = $this->mxchat_generate_response_deepseek(
7977 $selected_model,
7978 $deepseek_api_key,
7979 $conversation_history,
7980 $relevant_content
7981 );
7982
7983 // Save bot response to transcript
7984 if (!empty($regular_response) && !empty($session_id)) {
7985 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7986 }
7987
7988 $response_data = [
7989 'text' => $regular_response,
7990 'html' => '',
7991 'session_id' => $session_id
7992 ];
7993
7994 if ($testing_data !== null) {
7995 $response_data['testing_data'] = $testing_data;
7996 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
7997 }
7998
7999 header('Content-Type: application/json');
8000 echo json_encode($response_data);
8001 return true;
8002 }
8003
8004 // Prepare the request body with stream: true
8005 $body = json_encode([
8006 'model' => $selected_model,
8007 'messages' => $formatted_conversation,
8008 'temperature' => 0.8,
8009 'stream' => true
8010 ]);
8011
8012 // Setup streaming headers now that we know we're actually streaming
8013 $this->setup_streaming_headers();
8014
8015 // Use cURL for streaming support
8016 $ch = curl_init();
8017 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
8018 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8019 curl_setopt($ch, CURLOPT_POST, true);
8020 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8021 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8022 'Content-Type: application/json',
8023 'Authorization: Bearer ' . $deepseek_api_key
8024 ));
8025 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8026 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8027
8028 $full_response = ''; // Accumulate full response for saving
8029 $stream_started = false;
8030 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
8031
8032 // Buffer control for real-time streaming
8033 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
8034 // Send testing data as the first event if available
8035 if (!$stream_started && $testing_data !== null) {
8036 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8037 flush();
8038 $stream_started = true;
8039 //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
8040 }
8041
8042 // CRITICAL FIX: Append new data to buffer
8043 $buffer .= $data;
8044
8045 // Process complete lines only
8046 $lines = explode("\n", $buffer);
8047
8048 // CRITICAL FIX: Keep the last incomplete line in the buffer
8049 // The last element might be incomplete, so keep it in buffer
8050 $buffer = array_pop($lines);
8051
8052 foreach ($lines as $line) {
8053 // Skip empty lines
8054 if (trim($line) === '') {
8055 continue;
8056 }
8057
8058 // Only process lines that start with "data: "
8059 if (strpos($line, 'data: ') !== 0) {
8060 continue;
8061 }
8062
8063 $json_str = substr($line, 6); // Remove 'data: ' prefix
8064
8065 if (trim($json_str) === '[DONE]') {
8066 echo "data: [DONE]\n\n";
8067 flush();
8068 continue;
8069 }
8070
8071 // Try to decode JSON
8072 $json = json_decode(trim($json_str), true);
8073 if ($json && isset($json['choices'][0]['delta']['content'])) {
8074 $content = $json['choices'][0]['delta']['content'];
8075 $full_response .= $content; // Accumulate the full response
8076
8077 // Send as SSE format
8078 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8079 flush();
8080 }
8081 }
8082
8083 return strlen($data);
8084 });
8085
8086 $response = curl_exec($ch);
8087 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8088
8089 if (curl_errno($ch) || $http_code !== 200) {
8090 $curl_error = curl_error($ch);
8091 curl_close($ch);
8092
8093 // Log the specific error for debugging
8094 //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
8095
8096 // Fallback to regular response
8097 $regular_response = $this->mxchat_generate_response_deepseek(
8098 $selected_model,
8099 $deepseek_api_key,
8100 $conversation_history,
8101 $relevant_content
8102 );
8103
8104 // Handle error response from regular function
8105 if (is_array($regular_response) && isset($regular_response['error'])) {
8106 if ($testing_data !== null) {
8107 $regular_response['testing_data'] = $testing_data;
8108 }
8109 header('Content-Type: application/json');
8110 echo json_encode($regular_response);
8111 return true;
8112 }
8113
8114 $response_data = [
8115 'text' => $regular_response,
8116 'html' => '',
8117 'session_id' => $session_id
8118 ];
8119
8120 if ($testing_data !== null) {
8121 $response_data['testing_data'] = $testing_data;
8122 //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
8123 }
8124
8125 header('Content-Type: application/json');
8126 echo json_encode($response_data);
8127 return true;
8128 }
8129
8130 curl_close($ch);
8131
8132 // Save the complete response to maintain chat persistence
8133 if (!empty($full_response) && !empty($session_id)) {
8134 // Prepare RAG context for streaming response
8135 $rag_context_for_storage = null;
8136 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8137 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8138
8139 if ($has_rag_data || $has_action_data) {
8140 $rag_context_for_storage = [];
8141
8142 if ($has_rag_data) {
8143 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8144 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8145 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8146 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8147 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8148 }
8149
8150 if ($has_action_data) {
8151 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8152 }
8153 }
8154 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8155 }
8156
8157 return true; // Indicate streaming completed successfully
8158
8159 } catch (Exception $e) {
8160 //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
8161
8162 // Fallback to regular response
8163 $regular_response = $this->mxchat_generate_response_deepseek(
8164 $selected_model,
8165 $deepseek_api_key,
8166 $conversation_history,
8167 $relevant_content
8168 );
8169
8170 // Handle error response from regular function
8171 if (is_array($regular_response) && isset($regular_response['error'])) {
8172 if ($testing_data !== null) {
8173 $regular_response['testing_data'] = $testing_data;
8174 }
8175 header('Content-Type: application/json');
8176 echo json_encode($regular_response);
8177 return true;
8178 }
8179
8180 $response_data = [
8181 'text' => $regular_response,
8182 'html' => '',
8183 'session_id' => $session_id
8184 ];
8185
8186 if ($testing_data !== null) {
8187 $response_data['testing_data'] = $testing_data;
8188 //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
8189 }
8190
8191 header('Content-Type: application/json');
8192 echo json_encode($response_data);
8193 return true;
8194 }
8195 }
8196
8197
8198 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
8199 try {
8200 if (!is_array($conversation_history)) {
8201 $conversation_history = array();
8202 }
8203
8204 $bot_id = $this->get_current_bot_id('');
8205 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8206
8207 $formatted_conversation = array();
8208
8209 $formatted_conversation[] = array(
8210 'role' => 'system',
8211 'content' => $system_prompt_instructions . " " . $relevant_content
8212 );
8213
8214 foreach ($conversation_history as $message) {
8215 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8216 $role = $message['role'];
8217
8218 if ($role === 'bot' || $role === 'agent') {
8219 $role = 'assistant';
8220 }
8221 if (!in_array($role, ['system', 'assistant', 'user'])) {
8222 $role = 'user';
8223 }
8224
8225 $formatted_conversation[] = array(
8226 'role' => $role,
8227 'content' => $message['content']
8228 );
8229 }
8230 }
8231
8232 $body = json_encode([
8233 'model' => $selected_model,
8234 'messages' => $formatted_conversation,
8235 'temperature' => 1,
8236 ]);
8237
8238 $args = [
8239 'body' => $body,
8240 'headers' => [
8241 'Content-Type' => 'application/json',
8242 'Authorization' => 'Bearer ' . $openrouter_api_key,
8243 'HTTP-Referer' => home_url(),
8244 'X-Title' => get_bloginfo('name'),
8245 ],
8246 'timeout' => 60,
8247 'redirection' => 5,
8248 'blocking' => true,
8249 'httpversion' => '1.0',
8250 'sslverify' => true,
8251 ];
8252
8253 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', $args);
8254
8255 if (is_wp_error($response)) {
8256 $error_message = $response->get_error_message();
8257 return [
8258 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
8259 'error_code' => 'openrouter_connection_error',
8260 'provider' => 'openrouter'
8261 ];
8262 }
8263
8264 $status_code = wp_remote_retrieve_response_code($response);
8265 if ($status_code !== 200) {
8266 $response_body = wp_remote_retrieve_body($response);
8267 $decoded_response = json_decode($response_body, true);
8268
8269 $error_message = isset($decoded_response['error']['message'])
8270 ? $decoded_response['error']['message']
8271 : 'HTTP Error ' . $status_code;
8272
8273 return [
8274 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
8275 'error_code' => 'openrouter_api_error',
8276 'provider' => 'openrouter',
8277 'status_code' => $status_code
8278 ];
8279 }
8280
8281 $response_body = wp_remote_retrieve_body($response);
8282 $decoded_response = json_decode($response_body, true);
8283
8284 if (isset($decoded_response['choices'][0]['message']['content'])) {
8285 return trim($decoded_response['choices'][0]['message']['content']);
8286 } else {
8287 return [
8288 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
8289 'error_code' => 'openrouter_response_format_error',
8290 'provider' => 'openrouter'
8291 ];
8292 }
8293 } catch (Exception $e) {
8294 return [
8295 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
8296 'error_code' => 'openrouter_exception',
8297 'provider' => 'openrouter'
8298 ];
8299 }
8300 }
8301 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
8302
8303 // Get bot ID from session or request
8304 $bot_id = $this->get_current_bot_id($session_id);
8305
8306 // Get system prompt instructions using centralized function
8307 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8308
8309 // Clean and validate conversation history
8310 foreach ($conversation_history as &$message) {
8311 // Convert bot and agent roles to assistant
8312 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
8313 $message['role'] = 'assistant';
8314 }
8315
8316 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
8317 if (!in_array($message['role'], ['assistant', 'user'])) {
8318 $message['role'] = 'user';
8319 }
8320
8321 // Ensure content field exists
8322 if (!isset($message['content']) || empty($message['content'])) {
8323 $message['content'] = '';
8324 }
8325
8326 // Remove any unsupported fields
8327 $message = array_intersect_key($message, array_flip(['role', 'content']));
8328 }
8329
8330 // Add relevant content as the latest user message
8331 $conversation_history[] = [
8332 'role' => 'user',
8333 'content' => $relevant_content
8334 ];
8335
8336 // Build request body
8337 $body = json_encode([
8338 'model' => $selected_model,
8339 'max_tokens' => 1000,
8340 'temperature' => 0.8,
8341 'messages' => $conversation_history,
8342 'system' => $system_prompt_instructions
8343 ]);
8344
8345 // Set up API request
8346 $args = [
8347 'body' => $body,
8348 'headers' => [
8349 'Content-Type' => 'application/json',
8350 'x-api-key' => $claude_api_key,
8351 'anthropic-version' => '2023-06-01'
8352 ],
8353 'timeout' => 60,
8354 'redirection' => 5,
8355 'blocking' => true,
8356 'httpversion' => '1.0',
8357 'sslverify' => true,
8358 ];
8359
8360 // Make API request
8361 $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
8362
8363 // Check for WordPress errors
8364 if (is_wp_error($response)) {
8365 //error_log("Claude API request error: " . $response->get_error_message());
8366 return "Sorry, there was an error connecting to the API.";
8367 }
8368
8369 // Check HTTP response code
8370 $http_code = wp_remote_retrieve_response_code($response);
8371 if ($http_code !== 200) {
8372 $error_body = wp_remote_retrieve_body($response);
8373 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
8374
8375 // Try to extract error message from response
8376 $error_data = json_decode($error_body, true);
8377 $error_message = isset($error_data['error']['message']) ?
8378 $error_data['error']['message'] :
8379 "HTTP error " . $http_code;
8380
8381 return "Sorry, the API returned an error: " . $error_message;
8382 }
8383
8384 // Parse response
8385 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8386
8387 // Check for JSON decode errors
8388 if (json_last_error() !== JSON_ERROR_NONE) {
8389 //error_log("Claude API JSON decode error: " . json_last_error_msg());
8390 return "Sorry, there was an error processing the API response.";
8391 }
8392
8393 // Extract and validate response content
8394 if (isset($response_body['content']) &&
8395 is_array($response_body['content']) &&
8396 !empty($response_body['content']) &&
8397 isset($response_body['content'][0]['text'])) {
8398 return trim($response_body['content'][0]['text']);
8399 }
8400
8401 // Log unexpected response format
8402 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
8403 return "Sorry, I received an unexpected response format from the API.";
8404 }
8405 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
8406 try {
8407 // Ensure conversation_history is an array
8408 if (!is_array($conversation_history)) {
8409 $conversation_history = array();
8410 }
8411
8412 // Get bot ID from session or request
8413 $bot_id = $this->get_current_bot_id('');
8414
8415 // Get system prompt instructions using centralized function
8416 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8417
8418 // Create a new array for the formatted conversation
8419 $formatted_conversation = array();
8420
8421 // Add system message first
8422 $formatted_conversation[] = array(
8423 'role' => 'system',
8424 'content' => $system_prompt_instructions . " " . $relevant_content
8425 );
8426
8427 // Add the rest of the conversation history
8428 foreach ($conversation_history as $message) {
8429 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8430 $role = $message['role'];
8431
8432 // Convert roles to supported format
8433 if ($role === 'bot' || $role === 'agent') {
8434 $role = 'assistant';
8435 }
8436 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8437 $role = 'user';
8438 }
8439
8440 $formatted_conversation[] = array(
8441 'role' => $role,
8442 'content' => $message['content']
8443 );
8444 }
8445 }
8446
8447 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8448 $is_gpt5_model = (
8449 strpos($selected_model, 'gpt-5') === 0 ||
8450 $selected_model === 'gpt-5.2' ||
8451 $selected_model === 'gpt-5.1-2025-11-13' ||
8452 $selected_model === 'gpt-5' ||
8453 $selected_model === 'gpt-5-mini' ||
8454 $selected_model === 'gpt-5-nano'
8455 );
8456
8457 // Build request body with optimal settings for fast responses
8458 $request_body = [
8459 'model' => $selected_model,
8460 'messages' => $formatted_conversation,
8461 'temperature' => 1,
8462 'stream' => false
8463 ];
8464
8465 // Add reasoning_effort only for GPT-5 models that support it
8466 // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
8467 if ($is_gpt5_model && $selected_model !== 'gpt-5.2' && $selected_model !== 'gpt-5.1-chat-latest') {
8468 // GPT-5.1 uses 'low' instead of 'minimal'
8469 if ($selected_model === 'gpt-5.1-2025-11-13') {
8470 $request_body['reasoning_effort'] = 'low';
8471 } else {
8472 $request_body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
8473 }
8474 }
8475
8476 $body = json_encode($request_body);
8477
8478 $args = [
8479 'body' => $body,
8480 'headers' => [
8481 'Content-Type' => 'application/json',
8482 'Authorization' => 'Bearer ' . $api_key,
8483 ],
8484 'timeout' => 60,
8485 'redirection' => 5,
8486 'blocking' => true,
8487 'httpversion' => '1.0',
8488 'sslverify' => true,
8489 ];
8490
8491 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
8492
8493 if (is_wp_error($response)) {
8494 $error_message = $response->get_error_message();
8495 return [
8496 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
8497 'error_code' => 'openai_connection_error',
8498 'provider' => 'openai'
8499 ];
8500 }
8501
8502 $status_code = wp_remote_retrieve_response_code($response);
8503 if ($status_code !== 200) {
8504 $response_body = wp_remote_retrieve_body($response);
8505 $decoded_response = json_decode($response_body, true);
8506
8507 $error_message = isset($decoded_response['error']['message'])
8508 ? $decoded_response['error']['message']
8509 : 'HTTP Error ' . $status_code;
8510
8511 $error_type = isset($decoded_response['error']['type'])
8512 ? $decoded_response['error']['type']
8513 : 'unknown';
8514
8515 // Handle specific error types
8516 switch ($error_type) {
8517 case 'invalid_request_error':
8518 if (strpos($error_message, 'API key') !== false) {
8519 return [
8520 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
8521 'error_code' => 'openai_invalid_api_key',
8522 'provider' => 'openai'
8523 ];
8524 }
8525 break;
8526
8527 case 'authentication_error':
8528 return [
8529 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
8530 'error_code' => 'openai_auth_error',
8531 'provider' => 'openai'
8532 ];
8533
8534 case 'rate_limit_exceeded':
8535 return [
8536 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
8537 'error_code' => 'openai_rate_limit',
8538 'provider' => 'openai'
8539 ];
8540
8541 case 'quota_exceeded':
8542 return [
8543 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
8544 'error_code' => 'openai_quota_exceeded',
8545 'provider' => 'openai'
8546 ];
8547 }
8548
8549 // Generic error fallback
8550 return [
8551 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
8552 'error_code' => 'openai_api_error',
8553 'provider' => 'openai',
8554 'status_code' => $status_code
8555 ];
8556 }
8557
8558 $response_body = wp_remote_retrieve_body($response);
8559 $decoded_response = json_decode($response_body, true);
8560
8561 if (isset($decoded_response['choices'][0]['message']['content'])) {
8562 return trim($decoded_response['choices'][0]['message']['content']);
8563 } else {
8564 return [
8565 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
8566 'error_code' => 'openai_response_format_error',
8567 'provider' => 'openai'
8568 ];
8569 }
8570 } catch (Exception $e) {
8571 return [
8572 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
8573 'error_code' => 'openai_exception',
8574 'provider' => 'openai'
8575 ];
8576 }
8577 }
8578
8579 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
8580 try {
8581 // Get bot ID from session or request
8582 $bot_id = $this->get_current_bot_id($session_id);
8583
8584 // Get system prompt instructions using centralized function
8585 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8586
8587 // Add system prompt to relevant content
8588 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8589
8590 // Prepend system instructions to the conversation history
8591 array_unshift($conversation_history, [
8592 'role' => 'system',
8593 'content' => "Here are your instructions: " . $content_with_instructions
8594 ]);
8595
8596 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
8597 foreach ($conversation_history as &$message) {
8598 if ($message['role'] === 'bot') {
8599 $message['role'] = 'assistant';
8600 } elseif ($message['role'] === 'agent') {
8601 // Tag the message as coming from a live agent
8602 $message['role'] = 'assistant';
8603 if (!isset($message['metadata'])) {
8604 $message['metadata'] = ['source' => 'live_agent'];
8605 }
8606 }
8607
8608 // Ensure all roles are valid
8609 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
8610 $message['role'] = 'user'; // Default to 'user'
8611 }
8612 }
8613
8614 // Build the request body
8615 $body = json_encode([
8616 'model' => $selected_model,
8617 'messages' => $conversation_history,
8618 'temperature' => 0.8,
8619 'stream' => false
8620 ]);
8621
8622 // Set up the API request
8623 $args = [
8624 'body' => $body,
8625 'headers' => [
8626 'Content-Type' => 'application/json',
8627 'Authorization' => 'Bearer ' . $xai_api_key,
8628 ],
8629 'timeout' => 60,
8630 'redirection' => 5,
8631 'blocking' => true,
8632 'httpversion' => '1.0',
8633 'sslverify' => true,
8634 ];
8635
8636 // Make the API request
8637 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
8638
8639 // Process the response
8640 if (is_wp_error($response)) {
8641 $error_message = $response->get_error_message();
8642 //error_log('X.AI API Error: ' . $error_message);
8643 return [
8644 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
8645 'error_code' => 'xai_connection_error',
8646 'provider' => 'xai'
8647 ];
8648 }
8649
8650 $status_code = wp_remote_retrieve_response_code($response);
8651 if ($status_code !== 200) {
8652 $response_body = wp_remote_retrieve_body($response);
8653 $decoded_response = json_decode($response_body, true);
8654
8655 // Log the full response for debugging
8656 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
8657
8658 // Extract error message from X.AI's specific format
8659 $error_message = '';
8660
8661 // Check for direct error string (as seen in your logs)
8662 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
8663 $error_message = $decoded_response['error'];
8664 }
8665 // Check for nested error object (OpenAI style)
8666 elseif (isset($decoded_response['error']['message'])) {
8667 $error_message = $decoded_response['error']['message'];
8668 }
8669 // Check for top-level message
8670 elseif (isset($decoded_response['message'])) {
8671 $error_message = $decoded_response['message'];
8672 }
8673 // Fallback
8674 else {
8675 $error_message = 'HTTP Error ' . $status_code;
8676 }
8677
8678 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
8679
8680 // Check for API key errors using string matching
8681 if (stripos($error_message, 'api key') !== false ||
8682 stripos($error_message, 'incorrect api key') !== false ||
8683 stripos($error_message, 'invalid api key') !== false) {
8684 return [
8685 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
8686 'error_code' => 'xai_invalid_api_key',
8687 'provider' => 'xai'
8688 ];
8689 }
8690
8691 // Authentication errors
8692 if ($status_code === 401 || $status_code === 403 ||
8693 stripos($error_message, 'auth') !== false) {
8694 return [
8695 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
8696 'error_code' => 'xai_auth_error',
8697 'provider' => 'xai'
8698 ];
8699 }
8700
8701 // Model errors
8702 if (stripos($error_message, 'model') !== false) {
8703 return [
8704 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
8705 'error_code' => 'xai_invalid_model',
8706 'provider' => 'xai'
8707 ];
8708 }
8709
8710 // Rate limit errors
8711 if ($status_code === 429 ||
8712 stripos($error_message, 'rate') !== false ||
8713 stripos($error_message, 'limit') !== false) {
8714 return [
8715 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
8716 'error_code' => 'xai_rate_limit',
8717 'provider' => 'xai'
8718 ];
8719 }
8720
8721 // Quota errors
8722 if (stripos($error_message, 'quota') !== false ||
8723 stripos($error_message, 'billing') !== false) {
8724 return [
8725 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
8726 'error_code' => 'xai_quota_exceeded',
8727 'provider' => 'xai'
8728 ];
8729 }
8730
8731 // Server errors
8732 if ($status_code >= 500) {
8733 return [
8734 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
8735 'error_code' => 'xai_service_unavailable',
8736 'provider' => 'xai'
8737 ];
8738 }
8739
8740 // Generic error fallback with the actual error message
8741 return [
8742 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
8743 'error_code' => 'xai_api_error',
8744 'provider' => 'xai',
8745 'status_code' => $status_code
8746 ];
8747 }
8748
8749 $response_body = wp_remote_retrieve_body($response);
8750 $decoded_response = json_decode($response_body, true);
8751
8752 if (isset($decoded_response['choices'][0]['message']['content'])) {
8753 return trim($decoded_response['choices'][0]['message']['content']);
8754 } else {
8755 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
8756 return [
8757 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
8758 'error_code' => 'xai_response_format_error',
8759 'provider' => 'xai'
8760 ];
8761 }
8762 } catch (Exception $e) {
8763 //error_log('X.AI Exception: ' . $e->getMessage());
8764 return [
8765 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
8766 'error_code' => 'xai_exception',
8767 'provider' => 'xai'
8768 ];
8769 }
8770
8771
8772 }
8773 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
8774 try {
8775 // Ensure conversation_history is an array
8776 if (!is_array($conversation_history)) {
8777 $conversation_history = array();
8778 }
8779
8780 // Get bot ID from session or request
8781 $bot_id = $this->get_current_bot_id($session_id);
8782
8783 // Get system prompt instructions using centralized function
8784 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8785
8786 // Create a new array for the formatted conversation
8787 $formatted_conversation = array();
8788
8789 // Add system message first
8790 $formatted_conversation[] = array(
8791 'role' => 'system',
8792 'content' => $system_prompt_instructions . " " . $relevant_content
8793 );
8794
8795 // Add the rest of the conversation history
8796 foreach ($conversation_history as $message) {
8797 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8798 $role = $message['role'];
8799
8800 // Convert roles to supported format
8801 if ($role === 'bot' || $role === 'agent') {
8802 $role = 'assistant';
8803 }
8804 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8805 $role = 'user';
8806 }
8807
8808 $formatted_conversation[] = array(
8809 'role' => $role,
8810 'content' => $message['content']
8811 );
8812 }
8813 }
8814
8815 $body = json_encode([
8816 'model' => $selected_model,
8817 'messages' => $formatted_conversation,
8818 'temperature' => 0.8,
8819 'stream' => false
8820 ]);
8821
8822 $args = [
8823 'body' => $body,
8824 'headers' => [
8825 'Content-Type' => 'application/json',
8826 'Authorization' => 'Bearer ' . $deepseek_api_key,
8827 ],
8828 'timeout' => 60,
8829 'redirection' => 5,
8830 'blocking' => true,
8831 'httpversion' => '1.0',
8832 'sslverify' => true,
8833 ];
8834
8835 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
8836
8837 if (is_wp_error($response)) {
8838 $error_message = $response->get_error_message();
8839 //error_log('DeepSeek API Error: ' . $error_message);
8840 return [
8841 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
8842 'error_code' => 'deepseek_connection_error',
8843 'provider' => 'deepseek'
8844 ];
8845 }
8846
8847 $status_code = wp_remote_retrieve_response_code($response);
8848 if ($status_code !== 200) {
8849 $response_body = wp_remote_retrieve_body($response);
8850 $decoded_response = json_decode($response_body, true);
8851
8852 $error_message = isset($decoded_response['error']['message'])
8853 ? $decoded_response['error']['message']
8854 : 'HTTP Error ' . $status_code;
8855
8856 $error_type = isset($decoded_response['error']['type'])
8857 ? $decoded_response['error']['type']
8858 : 'unknown';
8859
8860 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
8861
8862 // Handle specific error types
8863 switch ($status_code) {
8864 case 401:
8865 return [
8866 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
8867 'error_code' => 'deepseek_auth_error',
8868 'provider' => 'deepseek'
8869 ];
8870
8871 case 400:
8872 if (strpos($error_message, 'API key') !== false) {
8873 return [
8874 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
8875 'error_code' => 'deepseek_invalid_api_key',
8876 'provider' => 'deepseek'
8877 ];
8878 }
8879 break;
8880
8881 case 429:
8882 if (strpos($error_message, 'quota') !== false) {
8883 return [
8884 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
8885 'error_code' => 'deepseek_quota_exceeded',
8886 'provider' => 'deepseek'
8887 ];
8888 } else {
8889 return [
8890 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
8891 'error_code' => 'deepseek_rate_limit',
8892 'provider' => 'deepseek'
8893 ];
8894 }
8895
8896 case 500:
8897 case 502:
8898 case 503:
8899 case 504:
8900 return [
8901 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
8902 'error_code' => 'deepseek_service_unavailable',
8903 'provider' => 'deepseek'
8904 ];
8905 }
8906
8907 // Generic error fallback
8908 return [
8909 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
8910 'error_code' => 'deepseek_api_error',
8911 'provider' => 'deepseek',
8912 'status_code' => $status_code
8913 ];
8914 }
8915
8916 $response_body = wp_remote_retrieve_body($response);
8917 $decoded_response = json_decode($response_body, true);
8918
8919 if (isset($decoded_response['choices'][0]['message']['content'])) {
8920 return trim($decoded_response['choices'][0]['message']['content']);
8921 } else {
8922 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
8923 return [
8924 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
8925 'error_code' => 'deepseek_response_format_error',
8926 'provider' => 'deepseek'
8927 ];
8928 }
8929 } catch (Exception $e) {
8930 //error_log('DeepSeek Exception: ' . $e->getMessage());
8931 return [
8932 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
8933 'error_code' => 'deepseek_exception',
8934 'provider' => 'deepseek'
8935 ];
8936 }
8937 }
8938 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
8939 // Get bot ID from session or request
8940 $bot_id = $this->get_current_bot_id($session_id);
8941
8942 // Get system prompt instructions using centralized function
8943 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8944
8945 // Add system prompt to relevant content
8946 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8947
8948 // Format messages for Gemini API
8949 $formatted_messages = [];
8950
8951 // Add system message as the first user message with role prefix
8952 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
8953 $formatted_messages[] = [
8954 'role' => 'user',
8955 'parts' => [
8956 ['text' => "[System Instructions] " . $content_with_instructions]
8957 ]
8958 ];
8959
8960 // Add model response to acknowledge system instructions
8961 $formatted_messages[] = [
8962 'role' => 'model',
8963 'parts' => [
8964 ['text' => "I understand and will follow these instructions."]
8965 ]
8966 ];
8967
8968 // Process the rest of the conversation history
8969 $current_role = null;
8970 $current_parts = [];
8971
8972 foreach ($conversation_history as $message) {
8973 // Skip the first system message as we already handled it
8974 if ($message['role'] === 'system') {
8975 continue;
8976 }
8977
8978 // Map roles to Gemini format
8979 $gemini_role = '';
8980 if ($message['role'] === 'user') {
8981 $gemini_role = 'user';
8982 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
8983 $gemini_role = 'model';
8984 } else {
8985 // Skip unsupported roles
8986 continue;
8987 }
8988
8989 // If we have a new role, add the previous message
8990 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
8991 $formatted_messages[] = [
8992 'role' => $current_role,
8993 'parts' => $current_parts
8994 ];
8995 $current_parts = [];
8996 }
8997
8998 // Set current role and add text to parts
8999 $current_role = $gemini_role;
9000 $current_parts[] = ['text' => $message['content']];
9001 }
9002
9003 // Add the last message if there's content
9004 if ($current_role !== null && !empty($current_parts)) {
9005 $formatted_messages[] = [
9006 'role' => $current_role,
9007 'parts' => $current_parts
9008 ];
9009 }
9010
9011 // Build the request body
9012 $body = json_encode([
9013 'contents' => $formatted_messages,
9014 'generationConfig' => [
9015 'temperature' => 0.7,
9016 'topP' => 0.95,
9017 'topK' => 40,
9018 'maxOutputTokens' => 8192,
9019 ],
9020 'safetySettings' => [
9021 [
9022 'category' => 'HARM_CATEGORY_HARASSMENT',
9023 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9024 ],
9025 [
9026 'category' => 'HARM_CATEGORY_HATE_SPEECH',
9027 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9028 ],
9029 [
9030 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
9031 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9032 ],
9033 [
9034 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
9035 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9036 ]
9037 ]
9038 ]);
9039
9040 // Prepare the API endpoint
9041 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models
9042 $api_version = (strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
9043 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
9044
9045 // Set up the API request
9046 $args = [
9047 'body' => $body,
9048 'headers' => [
9049 'Content-Type' => 'application/json',
9050 ],
9051 'timeout' => 60,
9052 'redirection' => 5,
9053 'blocking' => true,
9054 'httpversion' => '1.0',
9055 'sslverify' => true,
9056 ];
9057
9058 // Make the API request
9059 $response = wp_remote_post($api_endpoint, $args);
9060
9061 // Process the response
9062 if (is_wp_error($response)) {
9063 return "Sorry, there was an error processing your request: " . $response->get_error_message();
9064 }
9065
9066 $response_body = json_decode(wp_remote_retrieve_body($response), true);
9067
9068 // Handle potential errors in the response
9069 if (isset($response_body['error'])) {
9070 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
9071 return "Sorry, there was an error with the Gemini API: " .
9072 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
9073 }
9074
9075 // Extract the response text
9076 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
9077 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
9078 } else {
9079 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
9080 return "Sorry, I couldn't process that request. The response format was unexpected.";
9081 }
9082 }
9083
9084
9085 public function test_streaming_request() {
9086 $options = get_option('mxchat_options', []);
9087 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
9088
9089 // Detect provider from model prefix
9090 $provider = strtolower(explode('-', $model)[0]);
9091
9092 $sample_prompt = 'Hello! Can you stream this response back to me?';
9093 $messages = [['role' => 'user', 'content' => $sample_prompt]];
9094 $headers = [];
9095 $body = [];
9096 $url = '';
9097 $api_key = '';
9098
9099 switch ($provider) {
9100 case 'gpt':
9101 case 'o1':
9102 $api_key = $options['api_key'] ?? '';
9103 if (empty($api_key)) return '❌ Missing API key for OpenAI';
9104 $url = 'https://api.openai.com/v1/chat/completions';
9105 $headers = [
9106 'Content-Type: application/json',
9107 'Authorization: Bearer ' . $api_key
9108 ];
9109 $body = [
9110 'model' => $model,
9111 'messages' => $messages,
9112 'stream' => true
9113 ];
9114 break;
9115
9116 case 'claude':
9117 $api_key = $options['claude_api_key'] ?? '';
9118 if (empty($api_key)) return '❌ Missing API key for Claude';
9119 $url = 'https://api.anthropic.com/v1/messages';
9120 $headers = [
9121 'Content-Type: application/json',
9122 'x-api-key: ' . $api_key,
9123 'anthropic-version: 2023-06-01'
9124 ];
9125 $body = [
9126 'model' => $model,
9127 'messages' => $messages,
9128 'max_tokens' => 100,
9129 'stream' => true
9130 ];
9131 break;
9132
9133 case 'grok':
9134 $api_key = $options['xai_api_key'] ?? '';
9135 if (empty($api_key)) return '❌ Missing API key for X.AI';
9136 $url = 'https://api.x.ai/v1/chat/completions';
9137 $headers = [
9138 'Content-Type: application/json',
9139 'Authorization: Bearer ' . $api_key
9140 ];
9141 $body = [
9142 'model' => $model,
9143 'messages' => $messages,
9144 'stream' => true
9145 ];
9146 break;
9147
9148 case 'deepseek':
9149 if (empty($deepseek_api_key)) {
9150 $error_response = [
9151 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
9152 'error_code' => 'missing_deepseek_api_key'
9153 ];
9154 if ($testing_data !== null) {
9155 $error_response['testing_data'] = $testing_data;
9156 }
9157 return $error_response;
9158 }
9159 if ($streaming) {
9160 return $this->mxchat_generate_response_deepseek_stream(
9161 $selected_model,
9162 $deepseek_api_key,
9163 $conversation_history,
9164 $relevant_content,
9165 $session_id,
9166 $testing_data // Pass testing data
9167 );
9168 } else {
9169 $response = $this->mxchat_generate_response_deepseek(
9170 $selected_model,
9171 $deepseek_api_key,
9172 $conversation_history,
9173 $relevant_content
9174 );
9175 }
9176 break;
9177
9178 case 'gemini':
9179 $api_key = $options['gemini_api_key'] ?? '';
9180 if (empty($api_key)) return '❌ Missing API key for Gemini';
9181 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
9182 $headers = ['Content-Type: application/json'];
9183 $body = [
9184 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
9185 'generationConfig' => ['temperature' => 0.7]
9186 ];
9187 break;
9188
9189 default:
9190 return '❌ Unsupported provider: ' . $provider;
9191 }
9192
9193 // Do the actual streaming test
9194 $ch = curl_init($url);
9195 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
9196 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
9197 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
9198 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
9199 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9200
9201 $response = curl_exec($ch);
9202 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
9203 $error = curl_error($ch);
9204 curl_close($ch);
9205
9206 if ($error) return "❌ cURL error: $error";
9207 if ($http_code !== 200) {
9208 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
9209 return "❌ HTTP $http_code: $error_message";
9210 }
9211
9212 return true;
9213 }
9214
9215 public function mxchat_dismiss_pre_chat_message() {
9216 // Get and sanitize the user identifier
9217 $user_id = $this->mxchat_get_user_identifier();
9218 $user_id = sanitize_key($user_id);
9219
9220 // Set a transient to track that the user has dismissed the pre-chat message
9221 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9222 set_transient($transient_key, true, DAY_IN_SECONDS);
9223
9224 wp_send_json_success();
9225 }
9226
9227 public function mxchat_check_pre_chat_message_status() {
9228 // Get and sanitize the user identifier
9229 $user_id = $this->mxchat_get_user_identifier();
9230 $user_id = sanitize_key($user_id);
9231
9232 // Check if the transient exists (i.e., if the message was dismissed)
9233 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9234 $dismissed = get_transient($transient_key);
9235
9236 // Log the result to see if it's being set correctly
9237 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
9238
9239 if ($dismissed) {
9240 wp_send_json_success(['dismissed' => true]);
9241 } else {
9242 wp_send_json_success(['dismissed' => false]);
9243 }
9244
9245 wp_die();
9246 }
9247
9248 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
9249 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
9250 return 0;
9251 }
9252
9253 $dotProduct = array_sum(array_map(function ($a, $b) {
9254 return $a * $b;
9255 }, $vectorA, $vectorB));
9256 $normA = sqrt(array_sum(array_map(function ($a) {
9257 return $a * $a;
9258 }, $vectorA)));
9259 $normB = sqrt(array_sum(array_map(function ($b) {
9260 return $b * $b;
9261 }, $vectorB)));
9262
9263 if ($normA == 0 || $normB == 0) {
9264 return 0;
9265 }
9266
9267 return $dotProduct / ($normA * $normB);
9268 }
9269
9270
9271 public function mxchat_enqueue_scripts_styles() {
9272 // Fetch options from the database first to check loading strategy
9273 $this->options = get_option('mxchat_options');
9274 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9275
9276 // Always enqueue CSS immediately
9277 wp_enqueue_style(
9278 'mxchat-chat-css',
9279 plugin_dir_url(__FILE__) . '../css/chat-style.css',
9280 array(),
9281 MXCHAT_VERSION
9282 );
9283
9284 // Protect MxChat CSS from LiteSpeed UCSS/CCSS stripping via data-no-optimize attribute
9285 add_filter('style_loader_tag', function($tag, $handle) {
9286 if ($handle === 'mxchat-chat-css' || strpos($handle, 'mxchat') !== false) {
9287 $tag = str_replace("rel='stylesheet'", "rel='stylesheet' data-no-optimize='1'", $tag);
9288 $tag = str_replace('rel="stylesheet"', 'rel="stylesheet" data-no-optimize="1"', $tag);
9289 }
9290 return $tag;
9291 }, 10, 2);
9292
9293 // Handle script loading based on strategy
9294 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9295 // Enqueue the script normally
9296 wp_enqueue_script(
9297 'mxchat-chat-js',
9298 plugin_dir_url(__FILE__) . '../js/chat-script.js',
9299 array('jquery'),
9300 MXCHAT_VERSION,
9301 true
9302 );
9303
9304 // Add defer attribute if strategy is 'defer'
9305 if ($loading_strategy === 'defer') {
9306 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
9307 }
9308 } else {
9309 // For delay or interaction-based loading, we'll use a custom loader
9310 // Don't enqueue the main script - we'll load it dynamically
9311 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
9312 }
9313
9314 // Protect MxChat JS from LiteSpeed optimization stripping via data-no-optimize attribute
9315 add_filter('script_loader_tag', function($tag, $handle) {
9316 if ($handle === 'mxchat-chat-js' || strpos($handle, 'mxchat') !== false) {
9317 $tag = str_replace('<script ', '<script data-no-optimize="1" ', $tag);
9318 }
9319 return $tag;
9320 }, 10, 2);
9321 $prompts_options = get_option('mxchat_prompts_options', array());
9322
9323 // Check if AI theme is active - if so, skip inline colors in JavaScript
9324 $theme_options = get_option('mxchat_theme_options', array());
9325 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9326 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9327 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9328
9329 // Prepare settings for JavaScript
9330 $style_settings = array(
9331 'ajax_url' => admin_url('admin-ajax.php'),
9332 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9333 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9334 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9335 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9336 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9337 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9338 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9339 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9340 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9341 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9342 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9343 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9344 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9345 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9346 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9347 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9348 'icon_color' => $this->options['icon_color'] ?? '#fff',
9349 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9350 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9351 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9352 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9353 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9354 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9355 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9356 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9357 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9358 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9359 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
9360 'initial_email_state' => null, // Also fixed this undefined variable
9361 'skip_email_check' => true,
9362 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9363 'skip_inline_colors' => $skip_inline_colors,
9364 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9365 );
9366
9367 // For normal/defer loading, use wp_localize_script
9368 // For delayed loading, we store settings in a transient to be output inline
9369 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9370 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
9371 } else {
9372 // Store settings for the delayed loader to use
9373 set_transient('mxchat_delayed_settings_' . get_current_user_id(), $style_settings, 60);
9374 }
9375 }
9376
9377 /**
9378 * Output the delayed script loader for performance optimization
9379 */
9380 public function mxchat_output_delayed_script_loader() {
9381 $this->options = get_option('mxchat_options');
9382 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9383 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
9384
9385 // Get the stored settings
9386 $prompts_options = get_option('mxchat_prompts_options', array());
9387 $theme_options = get_option('mxchat_theme_options', array());
9388 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9389 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9390 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9391
9392 $style_settings = array(
9393 'ajax_url' => admin_url('admin-ajax.php'),
9394 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9395 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9396 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9397 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9398 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9399 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9400 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9401 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9402 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9403 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9404 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9405 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9406 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9407 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9408 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9409 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9410 'icon_color' => $this->options['icon_color'] ?? '#fff',
9411 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9412 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9413 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9414 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9415 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9416 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9417 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9418 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9419 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9420 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9421 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
9422 'initial_email_state' => null,
9423 'skip_email_check' => true,
9424 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9425 'skip_inline_colors' => $skip_inline_colors,
9426 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9427 );
9428
9429 // Determine delay time based on strategy
9430 $delay_ms = 0;
9431 switch ($loading_strategy) {
9432 case 'delay_1s':
9433 $delay_ms = 1000;
9434 break;
9435 case 'delay_3s':
9436 $delay_ms = 3000;
9437 break;
9438 case 'delay_5s':
9439 $delay_ms = 5000;
9440 break;
9441 }
9442
9443 ?>
9444 <script type="text/javascript">
9445 (function() {
9446 var mxchatLoaded = false;
9447 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
9448 window.mxchatChat = mxchatChat;
9449
9450 function loadMxChatScript() {
9451 if (mxchatLoaded) return;
9452 mxchatLoaded = true;
9453
9454 function appendChatScript() {
9455 var script = document.createElement('script');
9456 script.src = <?php echo wp_json_encode($script_url); ?>;
9457 script.type = 'text/javascript';
9458 document.body.appendChild(script);
9459 }
9460
9461 if (typeof jQuery !== 'undefined') {
9462 appendChatScript();
9463 } else {
9464 var jq = document.createElement('script');
9465 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
9466 jq.onload = appendChatScript;
9467 document.body.appendChild(jq);
9468 }
9469 }
9470
9471 <?php if ($loading_strategy === 'on_interaction'): ?>
9472 // Load on user interaction
9473 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
9474 events.forEach(function(evt) {
9475 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
9476 });
9477 // Fallback: load after 8 seconds if no interaction
9478 setTimeout(loadMxChatScript, 8000);
9479 <?php else: ?>
9480 // Load after specified delay
9481 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
9482 <?php endif; ?>
9483 })();
9484 </script>
9485 <?php
9486 }
9487
9488 /**
9489 * Setup the cron jobs for rate limits with guard against multiple calls
9490 */
9491 public function setup_rate_limit_cron_jobs() {
9492 // Add a guard to prevent multiple rapid calls
9493 $last_setup = get_transient('mxchat_cron_setup_guard');
9494 if ($last_setup && (time() - $last_setup) < 60) {
9495 // Don't run again if we ran less than 60 seconds ago
9496 return;
9497 }
9498
9499 // Set the guard
9500 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
9501
9502 try {
9503 // First, check if WordPress cron is disabled
9504 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
9505 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
9506 $this->setup_fallback_rate_limit_system();
9507 return;
9508 }
9509
9510 // Check if cron is already scheduled - if so, don't mess with it
9511 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
9512 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
9513 return;
9514 }
9515
9516 // Clear any orphaned hooks (but don't loop indefinitely)
9517 $hooks_to_clear = [
9518 'mxchat_reset_rate_limits',
9519 'mxchat_reset_hourly_rate_limits',
9520 'mxchat_reset_daily_rate_limits',
9521 'mxchat_reset_weekly_rate_limits',
9522 'mxchat_reset_monthly_rate_limits'
9523 ];
9524
9525 foreach ($hooks_to_clear as $hook) {
9526 // Only clear a maximum of 3 instances to prevent infinite loops
9527 $cleared = 0;
9528 while (wp_next_scheduled($hook) && $cleared < 3) {
9529 wp_clear_scheduled_hook($hook);
9530 $cleared++;
9531 }
9532 }
9533
9534 // Small delay after clearing
9535 usleep(100000); // 0.1 seconds
9536
9537 // Try to schedule the event
9538 $initial_time = time() + 300; // Start in 5 minutes
9539 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
9540
9541 if ($result === false) {
9542 //error_log('MxChat: Failed to schedule cron, using fallback system');
9543 $this->setup_fallback_rate_limit_system();
9544 } else {
9545 //error_log('MxChat: Successfully scheduled rate limit reset cron');
9546 }
9547
9548 } catch (Exception $e) {
9549 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
9550 $this->setup_fallback_rate_limit_system();
9551 }
9552 }
9553
9554 /**
9555 * Try alternative cron scheduling methods
9556 */
9557 private function try_alternative_cron_scheduling($initial_time) {
9558 try {
9559 // Method 1: Try with current time instead of future time
9560 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
9561 if ($result1 !== false) {
9562 //error_log('MxChat: Alternative method 1 (current time) succeeded');
9563 return true;
9564 }
9565
9566 // Method 2: Try with a different interval
9567 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
9568 if ($result2 !== false) {
9569 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
9570 return true;
9571 }
9572
9573 // Method 3: Try wp_schedule_single_event first, then recurring
9574 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
9575 if ($result3 !== false) {
9576 //error_log('MxChat: Alternative method 3 (single event) succeeded');
9577 // Schedule the next one manually in the handler
9578 return true;
9579 }
9580
9581 return false;
9582
9583 } catch (Exception $e) {
9584 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
9585 return false;
9586 }
9587 }
9588
9589 /**
9590 * Enhanced fallback rate limit system
9591 */
9592 private function setup_fallback_rate_limit_system() {
9593 // Set a flag to use database-based rate limit cleanup
9594 update_option('mxchat_use_fallback_rate_limits', true);
9595
9596 // Schedule a one-time check to happen on the next plugin load
9597 update_option('mxchat_next_rate_limit_check', time() + 3600);
9598
9599 // Also set up a more frequent fallback check (every 4 hours)
9600 update_option('mxchat_fallback_check_interval', 4 * 3600);
9601
9602 //error_log('MxChat: Fallback rate limit system activated');
9603 }
9604
9605 /**
9606 * Enhanced fallback check method
9607 */
9608 public function check_fallback_rate_limits() {
9609 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9610
9611 if (!$use_fallback) {
9612 return; // Regular cron is working
9613 }
9614
9615 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9616 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
9617
9618 if (time() >= $next_check) {
9619 //error_log('MxChat: Running fallback rate limit cleanup');
9620 $this->mxchat_reset_rate_limits();
9621
9622 // Schedule next check
9623 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
9624 }
9625 }
9626 /**
9627 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
9628 */
9629 public function check_rate_limit() {
9630 // Check if we need to run fallback cleanup
9631 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9632 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9633
9634 if ($use_fallback && time() >= $next_check) {
9635 $this->mxchat_reset_rate_limits();
9636 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
9637 }
9638
9639 // Get bot ID from current request context
9640 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9641
9642 // Get bot-specific options (includes rate limits if overridden)
9643 $bot_options = $this->get_bot_options($bot_id);
9644 $current_options = !empty($bot_options) ? $bot_options : $this->options;
9645
9646 // Use bot-specific rate limits if available, otherwise fall back to default
9647 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
9648
9649 // Determine user role or if logged out
9650 if (is_user_logged_in()) {
9651 $user = wp_get_current_user();
9652 $user_id = $user->ID;
9653
9654 // Get the user's primary role using reset() to safely get the first element
9655 $user_roles = $user->roles;
9656
9657 // Safely get the first role regardless of array key structure
9658 if (!empty($user_roles) && is_array($user_roles)) {
9659 $role = reset($user_roles); // This safely gets the first element regardless of key
9660 } else {
9661 $role = 'subscriber'; // Default to subscriber if no role found
9662 }
9663 } else {
9664 $role = 'logged_out';
9665 // Use IP address for non-logged-in users
9666 $user_id = $this->get_client_ip();
9667 }
9668
9669 // Check if rate limits are configured for this role
9670 if (!isset($rate_limits_source[$role])) {
9671 return true; // No limit set for this role
9672 }
9673
9674 $limit = $rate_limits_source[$role]['limit'];
9675
9676 // If unlimited, return true immediately
9677 if ($limit === 'unlimited') {
9678 return true;
9679 }
9680
9681 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
9682 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
9683 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
9684 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
9685
9686 // Include bot_id in option name so each bot has separate rate limits
9687 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
9688
9689 // Get the counter data
9690 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
9691
9692 // If first request or counter reset needed, set the initial timestamp
9693 if ($limit_data['count'] === 0) {
9694 $limit_data['timestamp'] = time();
9695 update_option($option_name, $limit_data);
9696 }
9697
9698 // Get the timeframe
9699 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
9700 $rate_limits_source[$role]['timeframe'] : 'daily';
9701
9702 // Check if the counter needs to be reset based on timeframe
9703 $current_time = time();
9704 $timestamp = $limit_data['timestamp'];
9705 $should_reset = false;
9706
9707 switch ($timeframe) {
9708 case 'hourly':
9709 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
9710 break;
9711 case 'daily':
9712 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
9713 break;
9714 case 'weekly':
9715 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
9716 break;
9717 case 'monthly':
9718 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
9719 break;
9720 }
9721
9722 // Reset the counter if the timeframe has passed
9723 if ($should_reset) {
9724 $limit_data = ['count' => 0, 'timestamp' => $current_time];
9725 update_option($option_name, $limit_data);
9726 }
9727
9728 // Check if user has exceeded their limit
9729 if ($limit_data['count'] >= intval($limit)) {
9730 // Get the custom message for this role
9731 $message = !empty($rate_limits_source[$role]['message'])
9732 ? $rate_limits_source[$role]['message']
9733 : __('Rate limit exceeded. Please try again later.', 'mxchat');
9734
9735 // Add timeframe information to the message if placeholders exist
9736 $timeframe_label = '';
9737 switch ($timeframe) {
9738 case 'hourly':
9739 $timeframe_label = __('hour', 'mxchat');
9740 break;
9741 case 'daily':
9742 $timeframe_label = __('day', 'mxchat');
9743 break;
9744 case 'weekly':
9745 $timeframe_label = __('week', 'mxchat');
9746 break;
9747 case 'monthly':
9748 $timeframe_label = __('month', 'mxchat');
9749 break;
9750 }
9751
9752 // Replace placeholders in the message
9753 $message = str_replace(
9754 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
9755 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
9756 $message
9757 );
9758
9759 // Process HTML links in the message
9760 $message = $this->process_rate_limit_message_html($message);
9761
9762 // Return error with the processed message
9763 return [
9764 'error' => true,
9765 'message' => $message
9766 ];
9767 }
9768
9769 // Increment the counter
9770 $limit_data['count']++;
9771 update_option($option_name, $limit_data);
9772
9773 return true;
9774 }
9775
9776 /**
9777 * Enhanced rate limit reset with better error handling
9778 */
9779 public function mxchat_reset_rate_limits() {
9780 try {
9781 global $wpdb;
9782 $all_options = get_option('mxchat_options', []);
9783 $current_time = time();
9784
9785 // Get rate limit options with a safer query and limit
9786 $option_names = $wpdb->get_col(
9787 $wpdb->prepare(
9788 "SELECT option_name FROM {$wpdb->options}
9789 WHERE option_name LIKE %s
9790 LIMIT 1000",
9791 'mxchat_chat_limit_%'
9792 )
9793 );
9794
9795 if (empty($option_names)) {
9796 return;
9797 }
9798
9799 $processed_count = 0;
9800 $max_processing_time = 30; // Maximum 30 seconds
9801 $start_time = time();
9802
9803 foreach ($option_names as $option_name) {
9804 // Check processing time limit
9805 if ((time() - $start_time) > $max_processing_time) {
9806 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
9807 break;
9808 }
9809
9810 // Parse the option name more safely
9811 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
9812 continue;
9813 }
9814
9815 $role_and_user = $matches[1] . '_' . $matches[2];
9816 $parts = explode('_', $role_and_user);
9817
9818 if (count($parts) < 2) {
9819 continue;
9820 }
9821
9822 // Extract role (everything except the last part which is user ID)
9823 $user_id_part = array_pop($parts);
9824 $role = implode('_', $parts);
9825
9826 // Skip if role doesn't exist in our settings
9827 if (!isset($all_options['rate_limits'][$role])) {
9828 // Clean up orphaned entries
9829 delete_option($option_name);
9830 continue;
9831 }
9832
9833 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
9834 $limit_data = get_option($option_name);
9835
9836 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
9837 // Clean up invalid entries
9838 delete_option($option_name);
9839 continue;
9840 }
9841
9842 $timestamp = $limit_data['timestamp'];
9843 $should_reset = false;
9844
9845 // Determine if we should reset based on the timeframe
9846 switch ($timeframe) {
9847 case 'hourly':
9848 $should_reset = ($current_time - $timestamp) >= 3600;
9849 break;
9850 case 'daily':
9851 $should_reset = ($current_time - $timestamp) >= 86400;
9852 break;
9853 case 'weekly':
9854 $should_reset = ($current_time - $timestamp) >= 604800;
9855 break;
9856 case 'monthly':
9857 $should_reset = ($current_time - $timestamp) >= 2592000;
9858 break;
9859 }
9860
9861 // Reset the counter if the timeframe has passed
9862 if ($should_reset) {
9863 delete_option($option_name);
9864 wp_cache_delete($option_name, 'options');
9865 $processed_count++;
9866 }
9867 }
9868
9869 // Clean up any orphaned cache entries
9870 wp_cache_delete('mxchat_all_chat_limits', 'options');
9871
9872 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
9873
9874 } catch (Exception $e) {
9875 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
9876 }
9877 }
9878
9879
9880 /**
9881 * Process HTML links in rate limit messages
9882 *
9883 * @param string $message The rate limit message
9884 * @return string The processed message with safe HTML links
9885 */
9886 private function process_rate_limit_message_html($message) {
9887 // Return original message if empty
9888 if (empty($message)) {
9889 return $message;
9890 }
9891
9892 // First, convert markdown links to HTML
9893 $message = $this->convert_markdown_links($message);
9894
9895 // Then, auto-convert any remaining plain URLs to links
9896 $message = $this->auto_link_urls($message);
9897
9898 // Allow basic HTML tags for links and formatting
9899 $allowed_tags = [
9900 'a' => [
9901 'href' => true,
9902 'target' => true,
9903 'rel' => true,
9904 'title' => true,
9905 'class' => true
9906 ],
9907 'strong' => [],
9908 'em' => [],
9909 'br' => [],
9910 'b' => [],
9911 'i' => [],
9912 'span' => ['class' => true]
9913 ];
9914
9915 // Sanitize but allow the specified HTML tags
9916 $processed_message = wp_kses($message, $allowed_tags);
9917
9918 // If wp_kses stripped everything, return the original message as plain text
9919 if (empty($processed_message) && !empty($message)) {
9920 // Strip all HTML and return plain text as fallback
9921 return wp_strip_all_tags($message);
9922 }
9923
9924 return $processed_message;
9925 }
9926
9927 /**
9928 * Convert markdown links to HTML
9929 *
9930 * @param string $text The text to process
9931 * @return string The text with markdown links converted to HTML
9932 */
9933 private function convert_markdown_links($text) {
9934 // Return original text if empty
9935 if (empty($text)) {
9936 return $text;
9937 }
9938
9939 // Pattern to match markdown links: [text](url)
9940 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
9941
9942 $processed_text = preg_replace_callback($pattern, function($matches) {
9943 $link_text = $matches[1];
9944 $url = $matches[2];
9945
9946 // Clean up any trailing punctuation from the URL
9947 $url = rtrim($url, '.,;:!?');
9948
9949 // Sanitize the link text and URL
9950 $safe_text = esc_html($link_text);
9951 $safe_url = esc_url($url);
9952
9953 // Create the HTML link
9954 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
9955 }, $text);
9956
9957 // If preg_replace_callback failed, return original text
9958 if ($processed_text === null) {
9959 return $text;
9960 }
9961
9962 return $processed_text;
9963 }
9964
9965 /**
9966 * Auto-convert plain URLs to clickable links
9967 *
9968 * @param string $text The text to process
9969 * @return string The text with URLs converted to links
9970 */
9971 private function auto_link_urls($text) {
9972 // Return original text if empty
9973 if (empty($text)) {
9974 return $text;
9975 }
9976
9977 // Simple pattern that avoids complex lookbehinds
9978 // This will match URLs that are not already inside href attributes or markdown links
9979 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
9980
9981 $processed_text = preg_replace_callback($pattern, function($matches) {
9982 $url = $matches[0];
9983 // Clean up any trailing punctuation that might have been captured
9984 $url = rtrim($url, '.,;:!?');
9985
9986 // Add target="_blank" and rel="noopener noreferrer" for security
9987 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
9988 }, $text);
9989
9990 // If preg_replace_callback failed, return original text
9991 if ($processed_text === null) {
9992 return $text;
9993 }
9994
9995 return $processed_text;
9996 }
9997
9998
9999 // Helper function to get client IP address
10000 private function get_client_ip() {
10001 // Check for shared internet/ISP IP
10002 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
10003 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
10004 }
10005
10006 // Check for IPs passing through proxies
10007 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
10008 // Use the first value in the comma-separated list
10009 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
10010 return trim($forwarded_for[0]);
10011 }
10012
10013 if (!empty($_SERVER['REMOTE_ADDR'])) {
10014 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
10015 }
10016
10017 // Fallback
10018 return 'unknown';
10019 }
10020
10021 /**
10022 * AJAX handler to get system information for testing panel
10023 */
10024 /**
10025 * AJAX handler to get system information for testing panel
10026 */
10027 public function mxchat_get_system_info() {
10028 // Verify nonce for security
10029 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10030 wp_send_json_error(['message' => 'Invalid nonce']);
10031 return;
10032 }
10033
10034 // Only allow admin users
10035 if (!current_user_can('administrator')) {
10036 wp_send_json_error(['message' => 'Unauthorized']);
10037 return;
10038 }
10039
10040 // Get system prompt from options
10041 $system_prompt = isset($this->options['system_prompt_instructions'])
10042 ? $this->options['system_prompt_instructions']
10043 : 'No system prompt configured';
10044
10045 // Get selected model
10046 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
10047
10048 // Check if OpenRouter is being used
10049 $is_openrouter = ($selected_model === 'openrouter');
10050 $openrouter_model = '';
10051
10052 if ($is_openrouter) {
10053 // Get the actual OpenRouter model that's selected
10054 $openrouter_model = isset($this->options['openrouter_selected_model'])
10055 ? $this->options['openrouter_selected_model']
10056 : 'No OpenRouter model selected';
10057
10058 // Update selected_model display to show both
10059 $selected_model = 'OpenRouter: ' . $openrouter_model;
10060 }
10061
10062 // Get API key status (just check if they exist, don't expose the keys)
10063 $api_status = [];
10064 $api_status['openai'] = !empty($this->options['api_key']);
10065 $api_status['claude'] = !empty($this->options['claude_api_key']);
10066 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
10067 $api_status['xai'] = !empty($this->options['xai_api_key']);
10068 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
10069 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
10070
10071 wp_send_json_success([
10072 'system_prompt' => $system_prompt,
10073 'selected_model' => $selected_model,
10074 'is_openrouter' => $is_openrouter,
10075 'openrouter_model' => $openrouter_model,
10076 'api_status' => $api_status
10077 ]);
10078 }
10079
10080 /**
10081 * AJAX handler to get similarity threshold
10082 */
10083 public function mxchat_get_similarity_threshold() {
10084 // Verify nonce for security
10085 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10086 wp_send_json_error(['message' => 'Invalid nonce']);
10087 return;
10088 }
10089
10090 // Only allow admin users
10091 if (!current_user_can('administrator')) {
10092 wp_send_json_error(['message' => 'Unauthorized']);
10093 return;
10094 }
10095
10096 // Get similarity threshold from main options (default 35%)
10097 $similarity_threshold = isset($this->options['similarity_threshold'])
10098 ? ((int) $this->options['similarity_threshold']) / 100
10099 : 0.35;
10100
10101 wp_send_json_success([
10102 'threshold' => $similarity_threshold,
10103 'threshold_percentage' => ($similarity_threshold * 100) . '%'
10104 ]);
10105 }
10106
10107 /**
10108 * AJAX handler to get knowledge base status
10109 */
10110 public function mxchat_get_kb_status() {
10111 // Verify nonce for security
10112 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10113 wp_send_json_error(['message' => 'Invalid nonce']);
10114 return;
10115 }
10116
10117 // Only allow admin users
10118 if (!current_user_can('administrator')) {
10119 wp_send_json_error(['message' => 'Unauthorized']);
10120 return;
10121 }
10122
10123 // Check OpenAI Vector Store first (takes priority)
10124 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
10125 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
10126
10127 if ($use_vectorstore) {
10128 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
10129 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
10130
10131 $kb_info = [
10132 'type' => 'OpenAI Vector Store',
10133 'status' => 'Active',
10134 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
10135 ];
10136
10137 wp_send_json_success($kb_info);
10138 return;
10139 }
10140
10141 // Check Pinecone vs WordPress
10142 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10143 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10144
10145 $kb_info = [
10146 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
10147 'status' => 'Active'
10148 ];
10149
10150 // Get document count
10151 if ($use_pinecone) {
10152 $kb_info['documents'] = 'Connected to Pinecone';
10153 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
10154 } else {
10155 // Count documents in WordPress database
10156 global $wpdb;
10157 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10158 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
10159 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
10160 }
10161
10162 wp_send_json_success($kb_info);
10163 }
10164
10165 /**
10166 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
10167 */
10168 public function mxchat_start_fresh_session() {
10169 // Verify nonce for security
10170 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10171 wp_send_json_error(['message' => 'Invalid nonce']);
10172 return;
10173 }
10174
10175 // Only allow admin users
10176 if (!current_user_can('administrator')) {
10177 wp_send_json_error(['message' => 'Unauthorized']);
10178 return;
10179 }
10180
10181 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
10182 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
10183
10184 if (empty($old_session_id)) {
10185 wp_send_json_error(['message' => 'Old session ID required']);
10186 return;
10187 }
10188
10189 // If no new session ID provided, generate one
10190 if (empty($new_session_id)) {
10191 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
10192 }
10193
10194 // Clear ALL data associated with the old session
10195 $this->clear_complete_session_data($old_session_id);
10196
10197 // Initialize the new session
10198 $this->initialize_fresh_session($new_session_id);
10199
10200 wp_send_json_success([
10201 'message' => 'Fresh session started successfully',
10202 'new_session_id' => $new_session_id,
10203 'old_session_id' => $old_session_id
10204 ]);
10205 }
10206
10207 /**
10208 * Clear ALL data associated with a session (ENHANCED)
10209 */
10210 private function clear_complete_session_data($session_id) {
10211 // Clear chat history
10212 delete_option("mxchat_history_{$session_id}");
10213
10214 // Clear chat mode
10215 delete_option("mxchat_mode_{$session_id}");
10216
10217 // Clear any PDF/Word transients
10218 $this->clear_pdf_transients($session_id);
10219 if (method_exists($this, 'clear_word_transients')) {
10220 $this->clear_word_transients($session_id);
10221 }
10222
10223 // Clear agent-related data
10224 delete_option("mxchat_channel_{$session_id}");
10225 delete_option("mxchat_agent_name_{$session_id}");
10226 delete_option("mxchat_email_{$session_id}");
10227
10228 // Clear any recommendation flow state
10229 delete_option("mxchat_sr_flow_state_{$session_id}");
10230
10231 // Clear any cached embeddings or context
10232 delete_transient("mxchat_context_{$session_id}");
10233 delete_transient("mxchat_last_query_{$session_id}");
10234
10235 // Clear any testing data
10236 delete_transient("mxchat_testing_data_{$session_id}");
10237
10238 // Clear any rate limiting data for this session
10239 delete_transient("mxchat_rate_limit_{$session_id}");
10240
10241 // Clear any other session-specific transients
10242 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
10243 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
10244 delete_transient("mxchat_include_word_in_context_{$session_id}");
10245
10246 // Clear form addon state (pending forms and submitted forms)
10247 delete_option("mxchat_pending_form_{$session_id}");
10248 delete_option("mxchat_submitted_forms_{$session_id}");
10249
10250 //error_log("MxChat: Cleared all data for session: {$session_id}");
10251 }
10252
10253 /**
10254 * Initialize a fresh session with default data
10255 */
10256 private function initialize_fresh_session($session_id) {
10257 // Set default chat mode
10258 update_option("mxchat_mode_{$session_id}", 'ai');
10259
10260 //error_log("MxChat: Initialized fresh session: {$session_id}");
10261 }
10262
10263 /**
10264 * Helper method to clear Word document transients (if you have Word support)
10265 */
10266 private function clear_word_transients($session_id) {
10267 delete_transient('mxchat_word_url_' . $session_id);
10268 delete_transient('mxchat_word_filename_' . $session_id);
10269 delete_transient('mxchat_word_embeddings_' . $session_id);
10270 delete_transient('mxchat_include_word_in_context_' . $session_id);
10271 }
10272
10273 /**
10274 * Simplified testing data capture method (CLEANED UP)
10275 */
10276 private function capture_testing_data($user_embedding, $message, $session_id) {
10277 // Only capture for admin users
10278 if (!current_user_can('administrator')) {
10279 return null;
10280 }
10281
10282 $testing_data = [
10283 'query' => $message,
10284 'timestamp' => time(),
10285 'top_matches' => [],
10286 'action_matches' => [] // Add action matches
10287 ];
10288
10289 // Get similarity threshold
10290 $similarity_threshold = isset($this->options['similarity_threshold'])
10291 ? ((int) $this->options['similarity_threshold']) / 100
10292 : 0.35;
10293
10294 $testing_data['similarity_threshold'] = $similarity_threshold;
10295
10296 // Use the real similarity analysis if available
10297 if ($this->last_similarity_analysis !== null) {
10298 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
10299 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
10300 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10301 } else {
10302 // Fallback: determine knowledge base type
10303 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10304 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10305
10306 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
10307 }
10308
10309 // Include action analysis if available
10310 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
10311 $testing_data['action_matches'] = $this->last_action_analysis;
10312
10313 // Clear it after capturing to avoid stale data
10314 $this->last_action_analysis = null;
10315 }
10316
10317 return $testing_data;
10318 }
10319
10320
10321 /**
10322 * Track URL clicks from chatbot responses
10323 */
10324 public function mxchat_track_url_click() {
10325 // Verify nonce for security
10326 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10327 wp_send_json_error(['message' => 'Invalid nonce']);
10328 wp_die();
10329 }
10330
10331 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10332 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
10333 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
10334
10335 if (empty($session_id) || empty($clicked_url)) {
10336 wp_send_json_error(['message' => 'Missing required data']);
10337 wp_die();
10338 }
10339
10340 global $wpdb;
10341 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10342
10343 // Insert click tracking record
10344 $wpdb->insert(
10345 $table_name,
10346 [
10347 'session_id' => $session_id,
10348 'clicked_url' => $clicked_url,
10349 'message_context' => $message_context,
10350 'click_timestamp' => current_time('mysql', 1),
10351 'user_ip' => $_SERVER['REMOTE_ADDR'],
10352 'user_agent' => $_SERVER['HTTP_USER_AGENT']
10353 ]
10354 );
10355
10356 wp_send_json_success(['message' => 'Click tracked']);
10357 wp_die();
10358 }
10359
10360 /**
10361 * Get URL click analytics for a session
10362 */
10363 public function mxchat_get_url_clicks($session_id) {
10364 global $wpdb;
10365 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10366
10367 $clicks = $wpdb->get_results($wpdb->prepare(
10368 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
10369 $session_id
10370 ));
10371
10372 return $clicks;
10373 }
10374 /**
10375 * Track the originating page where chat was started
10376 */
10377 public function mxchat_track_originating_page() {
10378 // Verify nonce
10379 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10380 wp_send_json_error(['message' => 'Invalid nonce']);
10381 wp_die();
10382 }
10383
10384 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10385 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
10386 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
10387
10388 if (empty($session_id)) {
10389 wp_send_json_error(['message' => 'Missing session ID']);
10390 wp_die();
10391 }
10392
10393 global $wpdb;
10394 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
10395
10396 // Check if we've already tracked for this session
10397 $existing = $wpdb->get_var($wpdb->prepare(
10398 "SELECT COUNT(*) FROM $table_name
10399 WHERE session_id = %s
10400 AND originating_page_url IS NOT NULL",
10401 $session_id
10402 ));
10403
10404 if ($existing > 0) {
10405 wp_send_json_success(['message' => 'Already tracked']);
10406 wp_die();
10407 }
10408
10409 // Update the first message in this session with originating page info
10410 $wpdb->query($wpdb->prepare(
10411 "UPDATE $table_name
10412 SET originating_page_url = %s,
10413 originating_page_title = %s
10414 WHERE session_id = %s
10415 ORDER BY timestamp ASC
10416 LIMIT 1",
10417 $page_url,
10418 $page_title,
10419 $session_id
10420 ));
10421
10422 wp_send_json_success(['message' => 'Originating page tracked']);
10423 wp_die();
10424 }
10425
10426 /**
10427 * Validate and clean URLs from AI response
10428 * Removes any URLs that aren't in the knowledge base
10429 *
10430 * @param string $response_text The AI-generated response
10431 * @param array $valid_urls Array of URLs from the knowledge base
10432 * @return string Cleaned response with invalid URLs removed/flagged
10433 */
10434 private function validate_and_clean_urls($response_text, $valid_urls) {
10435 // DEBUG: Log what we're working with
10436 error_log("=== MxChat URL Validation Debug ===");
10437 error_log("Valid URLs count: " . count($valid_urls));
10438 error_log("Valid URLs: " . print_r($valid_urls, true));
10439 error_log("Response text length: " . strlen($response_text));
10440 error_log("Response text preview: " . substr($response_text, 0, 500));
10441
10442 // If no valid URLs provided or empty response, return as-is
10443 if (empty($valid_urls) || empty($response_text)) {
10444 error_log("Validation skipped - empty valid_urls or response");
10445 return $response_text;
10446 }
10447
10448 // Extract all URLs from the AI response
10449 // This regex matches http:// and https:// URLs
10450 preg_match_all(
10451 '#\bhttps?://[^\s<>"\')\]]+#i',
10452 $response_text,
10453 $matches
10454 );
10455
10456 // If no URLs found in response, return as-is
10457 if (empty($matches[0])) {
10458 error_log("No URLs found in response");
10459 return $response_text;
10460 }
10461
10462 $found_urls = $matches[0];
10463 $cleaned_response = $response_text;
10464 $removed_count = 0;
10465
10466 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
10467 $normalized_valid_urls = array_map(function($url) {
10468 // Remove trailing slash
10469 $url = rtrim($url, '/');
10470 // Remove URL fragments (#section)
10471 $url = preg_replace('/#.*$/', '', $url);
10472 // Remove trailing punctuation that might have been captured
10473 $url = rtrim($url, '.,;:!?');
10474 return $url;
10475 }, $valid_urls);
10476
10477 error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
10478
10479 foreach ($found_urls as $found_url) {
10480 // Clean up the found URL (remove trailing punctuation that might have been captured)
10481 $clean_found_url = rtrim($found_url, '.,;:!?)');
10482
10483 // DEBUG: Log each URL being checked
10484 error_log("Checking found URL: " . $found_url);
10485
10486 // Normalize for comparison
10487 $normalized_found = rtrim($clean_found_url, '/');
10488 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
10489
10490 error_log("Normalized found URL: " . $normalized_found);
10491
10492 // Check if this URL exists in our valid URLs list
10493 $is_valid = false;
10494
10495 error_log("Starting validation checks for: " . $normalized_found);
10496
10497 // First, try exact match
10498 if (in_array($normalized_found, $normalized_valid_urls)) {
10499 $is_valid = true;
10500 error_log("EXACT MATCH FOUND");
10501 } else {
10502 error_log("No exact match, checking variations...");
10503 // If no exact match, check if it's a variation (with query params, etc.)
10504 foreach ($normalized_valid_urls as $valid_url) {
10505 error_log(" Comparing against valid URL: " . $valid_url);
10506
10507 // Check if the found URL starts with a valid URL (handles query params)
10508 if (strpos($normalized_found, $valid_url) === 0) {
10509 // Check what comes after the valid URL
10510 $remainder = substr($normalized_found, strlen($valid_url));
10511
10512 // Only valid if:
10513 // 1. Exact match (remainder is empty)
10514 // 2. Query params (starts with ?)
10515 // 3. Fragment (starts with #)
10516 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
10517 $is_valid = true;
10518 error_log(" MATCH: Found URL is valid variation of base URL");
10519 break;
10520 } else {
10521 error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
10522 }
10523 }
10524 // Also check the reverse (in case valid URL has query params)
10525 if (strpos($valid_url, $normalized_found) === 0) {
10526 $is_valid = true;
10527 error_log(" MATCH: Valid URL starts with found URL");
10528 break;
10529 }
10530 }
10531
10532 if (!$is_valid) {
10533 error_log("NO MATCH FOUND - URL should be removed");
10534 }
10535 }
10536
10537 // If URL is not valid, remove it from the response
10538 if (!$is_valid) {
10539 // Log the removal for debugging
10540 error_log("MxChat: Removed hallucinated URL: " . $found_url);
10541 error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
10542
10543 $removed_count++;
10544
10545 // Check if URL is part of a markdown link: [text](url)
10546 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
10547 if (preg_match($markdown_pattern, $cleaned_response)) {
10548 error_log("Found markdown link, removing but keeping text");
10549 // Remove the markdown link but keep the text
10550 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
10551 }
10552 // Check if URL is part of an HTML link: <a href="url">text</a>
10553 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
10554 error_log("Found HTML link, removing but keeping text");
10555 // Remove the HTML link but keep the text
10556 $link_text = $link_match[1];
10557 $cleaned_response = preg_replace(
10558 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
10559 $link_text,
10560 $cleaned_response
10561 );
10562 }
10563 // Otherwise just remove the bare URL
10564 else {
10565 error_log("Removing bare URL");
10566 $cleaned_response = str_replace($found_url, '', $cleaned_response);
10567 }
10568 }
10569 }
10570
10571 // Log summary if any URLs were removed
10572 if ($removed_count > 0) {
10573 error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
10574 } else {
10575 error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
10576 }
10577
10578 // Clean up any double spaces or awkward punctuation left behind
10579 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
10580 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
10581 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
10582
10583 error_log("Final cleaned response: " . $cleaned_response);
10584
10585 return trim($cleaned_response);
10586 }
10587
10588 /**
10589 * AJAX handler to get current chat mode for a session
10590 */
10591 public function mxchat_get_current_chat_mode() {
10592 // Verify nonce for security
10593 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10594 wp_send_json_error(['message' => 'Invalid nonce']);
10595 wp_die();
10596 }
10597
10598 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10599
10600 if (empty($session_id)) {
10601 wp_send_json_error(['message' => 'Session ID missing']);
10602 wp_die();
10603 }
10604
10605 // Get the current chat mode for this session
10606 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
10607
10608 wp_send_json_success([
10609 'chat_mode' => $chat_mode
10610 ]);
10611 wp_die();
10612 }
10613
10614
10615
10616 }
10617 ?>
10618