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

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